diff --git a/.core_files.yaml b/.core_files.yaml index 0817d5c8261..f5ffdee9142 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -45,6 +45,7 @@ base_platforms: &base_platforms - homeassistant/components/switch/** - homeassistant/components/text/** - homeassistant/components/time/** + - homeassistant/components/todo/** - homeassistant/components/tts/** - homeassistant/components/update/** - homeassistant/components/vacuum/** @@ -96,8 +97,8 @@ components: &components - homeassistant/components/persistent_notification/** - homeassistant/components/person/** - homeassistant/components/recorder/** + - homeassistant/components/recovery_mode/** - homeassistant/components/repairs/** - - homeassistant/components/safe_mode/** - homeassistant/components/script/** - homeassistant/components/shopping_list/** - homeassistant/components/ssdp/** diff --git a/.coveragerc b/.coveragerc index 533fd8de18d..5ef7ece3bd8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -178,6 +178,8 @@ omit = homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py homeassistant/components/comelit/light.py + homeassistant/components/comelit/sensor.py + homeassistant/components/comelit/switch.py homeassistant/components/comfoconnect/fan.py homeassistant/components/concord232/alarm_control_panel.py homeassistant/components/concord232/binary_sensor.py @@ -284,9 +286,6 @@ omit = homeassistant/components/edl21/__init__.py homeassistant/components/edl21/sensor.py homeassistant/components/egardia/* - homeassistant/components/eight_sleep/__init__.py - homeassistant/components/eight_sleep/binary_sensor.py - homeassistant/components/eight_sleep/sensor.py homeassistant/components/electric_kiwi/__init__.py homeassistant/components/electric_kiwi/api.py homeassistant/components/electric_kiwi/oauth2.py @@ -376,6 +375,7 @@ omit = homeassistant/components/fibaro/binary_sensor.py homeassistant/components/fibaro/climate.py homeassistant/components/fibaro/cover.py + homeassistant/components/fibaro/event.py homeassistant/components/fibaro/light.py homeassistant/components/fibaro/lock.py homeassistant/components/fibaro/sensor.py @@ -541,12 +541,6 @@ omit = homeassistant/components/hvv_departures/__init__.py homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py - homeassistant/components/hydrawise/__init__.py - homeassistant/components/hydrawise/binary_sensor.py - homeassistant/components/hydrawise/const.py - homeassistant/components/hydrawise/coordinator.py - homeassistant/components/hydrawise/sensor.py - homeassistant/components/hydrawise/switch.py homeassistant/components/ialarm/alarm_control_panel.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py @@ -563,7 +557,6 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/imap_email_content/sensor.py homeassistant/components/incomfort/* homeassistant/components/insteon/binary_sensor.py homeassistant/components/insteon/climate.py @@ -750,11 +743,6 @@ omit = homeassistant/components/mikrotik/hub.py homeassistant/components/mill/climate.py homeassistant/components/mill/sensor.py - homeassistant/components/minecraft_server/__init__.py - homeassistant/components/minecraft_server/binary_sensor.py - homeassistant/components/minecraft_server/coordinator.py - homeassistant/components/minecraft_server/entity.py - homeassistant/components/minecraft_server/sensor.py homeassistant/components/minio/minio_helper.py homeassistant/components/mjpeg/camera.py homeassistant/components/mjpeg/util.py @@ -795,6 +783,7 @@ omit = homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py homeassistant/components/mystrom/switch.py + homeassistant/components/mystrom/sensor.py homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/__init__.py homeassistant/components/nanoleaf/button.py @@ -835,7 +824,6 @@ omit = homeassistant/components/nibe_heatpump/__init__.py homeassistant/components/nibe_heatpump/climate.py homeassistant/components/nibe_heatpump/binary_sensor.py - homeassistant/components/nibe_heatpump/number.py homeassistant/components/nibe_heatpump/select.py homeassistant/components/nibe_heatpump/sensor.py homeassistant/components/nibe_heatpump/switch.py @@ -964,6 +952,7 @@ omit = homeassistant/components/ping/__init__.py homeassistant/components/ping/binary_sensor.py homeassistant/components/ping/device_tracker.py + homeassistant/components/ping/helpers.py homeassistant/components/pioneer/media_player.py homeassistant/components/plaato/__init__.py homeassistant/components/plaato/binary_sensor.py @@ -1000,6 +989,7 @@ omit = homeassistant/components/pushsafer/notify.py homeassistant/components/pyload/sensor.py homeassistant/components/qbittorrent/__init__.py + homeassistant/components/qbittorrent/coordinator.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/__init__.py homeassistant/components/qnap/coordinator.py @@ -1122,7 +1112,6 @@ omit = homeassistant/components/sesame/lock.py homeassistant/components/seven_segments/image_processing.py homeassistant/components/seventeentrack/sensor.py - homeassistant/components/shiftr/* homeassistant/components/shodan/sensor.py homeassistant/components/sia/__init__.py homeassistant/components/sia/alarm_control_panel.py @@ -1276,6 +1265,7 @@ omit = homeassistant/components/switchbot/sensor.py homeassistant/components/switchbot/switch.py homeassistant/components/switchbot/lock.py + homeassistant/components/switchbot_cloud/climate.py homeassistant/components/switchbot_cloud/coordinator.py homeassistant/components/switchbot_cloud/entity.py homeassistant/components/switchbot_cloud/switch.py @@ -1301,6 +1291,7 @@ omit = homeassistant/components/system_bridge/__init__.py homeassistant/components/system_bridge/binary_sensor.py homeassistant/components/system_bridge/coordinator.py + homeassistant/components/system_bridge/media_player.py homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py @@ -1400,6 +1391,7 @@ omit = homeassistant/components/trafikverket_weatherstation/coordinator.py homeassistant/components/trafikverket_weatherstation/sensor.py homeassistant/components/transmission/__init__.py + homeassistant/components/transmission/coordinator.py homeassistant/components/transmission/sensor.py homeassistant/components/transmission/switch.py homeassistant/components/travisci/sensor.py @@ -1474,7 +1466,9 @@ omit = homeassistant/components/vicare/binary_sensor.py homeassistant/components/vicare/button.py homeassistant/components/vicare/climate.py + homeassistant/components/vicare/entity.py homeassistant/components/vicare/sensor.py + homeassistant/components/vicare/utils.py homeassistant/components/vicare/water_heater.py homeassistant/components/vilfo/__init__.py homeassistant/components/vilfo/sensor.py @@ -1516,7 +1510,6 @@ omit = homeassistant/components/wiffi/sensor.py homeassistant/components/wiffi/wiffi_strings.py homeassistant/components/wirelesstag/* - homeassistant/components/withings/api.py homeassistant/components/wolflink/__init__.py homeassistant/components/wolflink/sensor.py homeassistant/components/worldtidesinfo/sensor.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 20d158ed676..c73a7bac340 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@v4.1.0 + uses: actions/checkout@v4.1.1 with: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -56,10 +56,10 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -98,7 +98,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -252,7 +252,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set build additional args run: | @@ -289,7 +289,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -327,7 +327,7 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Install Cosign uses: sigstore/cosign-installer@v3.1.2 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 053877b608e..7a5c3efd1cb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,18 +37,20 @@ env: PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 5 BLACK_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2023.10" + HA_SHORT_VERSION: "2023.11" DEFAULT_PYTHON: "3.11" - ALL_PYTHON_VERSIONS: "['3.11']" + ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # 10.6 is the current long-term-support # - 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) + # 10.11 is the latest long-term-support + # - 10.11.2 is the version currently shipped with Synology (as of 11 Oct 2023) # 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']" + MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','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 @@ -89,7 +91,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -222,10 +224,10 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -267,9 +269,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -335,9 +337,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -384,9 +386,9 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -478,10 +480,10 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -546,10 +548,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -578,10 +580,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -611,10 +613,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -655,10 +657,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -737,10 +739,10 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -889,10 +891,10 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1013,10 +1015,10 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1108,7 +1110,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000000..da7021e9df3 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,39 @@ +name: "CodeQL" + +# yamllint disable-line rule:truthy +on: + push: + branches: + - dev + - rc + - master + schedule: + - cron: "30 18 * * 4" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 360 + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.1.1 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2.22.4 + with: + languages: python + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2.22.4 + with: + category: "/language:python" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 84d7fc03e43..f72b71b8802 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@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 85912623f61..3b23f1b5b05 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -10,8 +10,10 @@ on: - dev - rc paths: - - "requirements.txt" + - ".github/workflows/wheels.yml" + - "homeassistant/package_constraints.txt" - "requirements_all.txt" + - "requirements.txt" concurrency: group: ${{ github.workflow }}-${{ github.ref_name}} @@ -26,7 +28,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Get information id: info @@ -80,11 +82,11 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp311"] + abi: ["cp311", "cp312"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Download env_file uses: actions/download-artifact@v3 @@ -97,7 +99,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.10.5 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -110,7 +112,7 @@ jobs: requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" - integrations_cp311: + integrations: name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} if: github.repository_owner == 'home-assistant' needs: init @@ -118,11 +120,11 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp311"] + abi: ["cp311", "cp312"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Download env_file uses: actions/download-artifact@v3 @@ -168,6 +170,18 @@ jobs: split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all.txt requirements_all.txt + - name: Create requirements for cython<3 + run: | + # Some dependencies still require 'cython<3' + # and don't yet use isolated build environments. + # Build these first. + # grpcio: https://github.com/grpc/grpc/issues/33918 + # pydantic: https://github.com/pydantic/pydantic/issues/7689 + + touch requirements_old-cython.txt + cat homeassistant/package_constraints.txt | grep 'grpcio==' >> requirements_old-cython.txt + cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt + - name: Adjust build env run: | if [ "${{ matrix.arch }}" = "i386" ]; then @@ -177,8 +191,23 @@ jobs: # Do not pin numpy in wheels building sed -i "/numpy/d" homeassistant/package_constraints.txt + - name: Build wheels (old cython) + uses: home-assistant/wheels@2023.10.5 + 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;charset-normalizer;grpcio;SQLAlchemy;protobuf + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_old-cython.txt" + pip: "'cython<3'" + - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.10.5 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -192,7 +221,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.10.5 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -206,7 +235,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.10.5 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 diff --git a/.gitignore b/.gitignore index ff20c088eb2..8a4154e4769 100644 --- a/.gitignore +++ b/.gitignore @@ -111,9 +111,6 @@ virtualization/vagrant/config !.vscode/tasks.json .env -# Built docs -docs/build - # Windows Explorer desktop.ini /home-assistant.pyproj diff --git a/.hadolint.yaml b/.hadolint.yaml index 06de09b5460..2010a459a5f 100644 --- a/.hadolint.yaml +++ b/.hadolint.yaml @@ -3,3 +3,4 @@ ignored: - DL3008 - DL3013 - DL3018 + - DL3042 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0c98143300..d9cca711131 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.289 + rev: v0.1.1 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.9.1 + rev: 23.10.0 hooks: - id: black args: diff --git a/.prettierignore b/.prettierignore index aab23e23078..07637a380c5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,6 @@ *.md .strict-typing azure-*.yml -docs/source/_templates/* homeassistant/components/*/translations/*.json homeassistant/generated/* tests/components/lidarr/fixtures/initialize.js diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index 1a91abd9a99..00000000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,14 +0,0 @@ -# .readthedocs.yml - -version: 2 - -build: - os: ubuntu-20.04 - tools: - python: "3.9" - -python: - install: - - method: setuptools - path: . - - requirements: requirements_docs.txt diff --git a/.strict-typing b/.strict-typing index 6b2c52f42f6..1faf190a1de 100644 --- a/.strict-typing +++ b/.strict-typing @@ -103,6 +103,7 @@ homeassistant.components.devolo_home_control.* homeassistant.components.devolo_home_network.* homeassistant.components.dhcp.* homeassistant.components.diagnostics.* +homeassistant.components.discovergy.* homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* homeassistant.components.doorbird.* @@ -156,15 +157,7 @@ homeassistant.components.homeassistant_green.* homeassistant.components.homeassistant_hardware.* homeassistant.components.homeassistant_sky_connect.* homeassistant.components.homeassistant_yellow.* -homeassistant.components.homekit -homeassistant.components.homekit.accessories -homeassistant.components.homekit.aidmanager -homeassistant.components.homekit.config_flow -homeassistant.components.homekit.diagnostics -homeassistant.components.homekit.logbook -homeassistant.components.homekit.type_locks -homeassistant.components.homekit.type_triggers -homeassistant.components.homekit.util +homeassistant.components.homekit.* homeassistant.components.homekit_controller homeassistant.components.homekit_controller.alarm_control_panel homeassistant.components.homekit_controller.button @@ -211,6 +204,7 @@ homeassistant.components.light.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* homeassistant.components.local_ip.* +homeassistant.components.local_todo.* homeassistant.components.lock.* homeassistant.components.logbook.* homeassistant.components.logger.* @@ -327,6 +321,7 @@ homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* homeassistant.components.tailscale.* +homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.text.* @@ -343,6 +338,7 @@ homeassistant.components.trafikverket_camera.* homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* +homeassistant.components.transmission.* homeassistant.components.trend.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* @@ -366,6 +362,7 @@ homeassistant.components.webostv.* homeassistant.components.websocket_api.* homeassistant.components.wemo.* homeassistant.components.whois.* +homeassistant.components.withings.* homeassistant.components.wiz.* homeassistant.components.wled.* homeassistant.components.worldclock.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c767647f821..b8cb8a4e61a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -16,7 +16,7 @@ { "label": "Pytest", "type": "shell", - "command": "pytest --timeout=10 tests", + "command": "python3 -m pytest --timeout=10 tests", "dependsOn": ["Install all Test Requirements"], "group": { "kind": "test", @@ -31,7 +31,7 @@ { "label": "Pytest (changed tests only)", "type": "shell", - "command": "pytest --timeout=10 --picked", + "command": "python3 -m pytest --timeout=10 --picked", "group": { "kind": "test", "isDefault": true @@ -75,7 +75,7 @@ "label": "Code Coverage", "detail": "Generate code coverage report for a given integration.", "type": "shell", - "command": "pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", + "command": "python3 -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", "group": { "kind": "test", "isDefault": true diff --git a/CODEOWNERS b/CODEOWNERS index 6a8c6489739..b9cce3b9047 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -100,8 +100,8 @@ build.json @home-assistant/supervisor /tests/components/apprise/ @caronc /homeassistant/components/aprs/ @PhilRW /tests/components/aprs/ @PhilRW -/homeassistant/components/aranet/ @aschmitz -/tests/components/aranet/ @aschmitz +/homeassistant/components/aranet/ @aschmitz @thecode +/tests/components/aranet/ @aschmitz @thecode /homeassistant/components/arcam_fmj/ @elupus /tests/components/arcam_fmj/ @elupus /homeassistant/components/arris_tg2492lg/ @vanbalken @@ -233,8 +233,8 @@ build.json @home-assistant/supervisor /tests/components/counter/ @fabaff /homeassistant/components/cover/ @home-assistant/core /tests/components/cover/ @home-assistant/core -/homeassistant/components/cpuspeed/ @fabaff @frenck -/tests/components/cpuspeed/ @fabaff @frenck +/homeassistant/components/cpuspeed/ @fabaff +/tests/components/cpuspeed/ @fabaff /homeassistant/components/crownstone/ @Crownstone @RicArch97 /tests/components/crownstone/ @Crownstone @RicArch97 /homeassistant/components/cups/ @fabaff @@ -319,8 +319,6 @@ build.json @home-assistant/supervisor /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt -/homeassistant/components/eight_sleep/ @mezz64 @raman325 -/tests/components/eight_sleep/ @mezz64 @raman325 /homeassistant/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili /homeassistant/components/electric_kiwi/ @mikey0000 @@ -423,8 +421,8 @@ build.json @home-assistant/supervisor /tests/components/fritzbox/ @mib1185 @flabbamann /homeassistant/components/fritzbox_callmonitor/ @cdce8p /tests/components/fritzbox_callmonitor/ @cdce8p -/homeassistant/components/fronius/ @nielstron @farmio -/tests/components/fronius/ @nielstron @farmio +/homeassistant/components/fronius/ @farmio +/tests/components/fronius/ @farmio /homeassistant/components/frontend/ @home-assistant/frontend /tests/components/frontend/ @home-assistant/frontend /homeassistant/components/frontier_silicon/ @wlcrs @@ -479,6 +477,8 @@ build.json @home-assistant/supervisor /tests/components/google_mail/ @tkdrob /homeassistant/components/google_sheets/ @tkdrob /tests/components/google_sheets/ @tkdrob +/homeassistant/components/google_tasks/ @allenporter +/tests/components/google_tasks/ @allenporter /homeassistant/components/google_travel_time/ @eifinger /tests/components/google_travel_time/ @eifinger /homeassistant/components/govee_ble/ @bdraco @PierreAronnax @@ -586,6 +586,8 @@ build.json @home-assistant/supervisor /tests/components/image_upload/ @home-assistant/core /homeassistant/components/imap/ @jbouwh /tests/components/imap/ @jbouwh +/homeassistant/components/improv_ble/ @emontnemery +/tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @zxdavb /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 @@ -660,8 +662,8 @@ build.json @home-assistant/supervisor /tests/components/kmtronic/ @dgomes /homeassistant/components/knx/ @Julius2342 @farmio @marvin-w /tests/components/knx/ @Julius2342 @farmio @marvin-w -/homeassistant/components/kodi/ @OnFreund @cgtobi -/tests/components/kodi/ @OnFreund @cgtobi +/homeassistant/components/kodi/ @OnFreund +/tests/components/kodi/ @OnFreund /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm @@ -708,6 +710,8 @@ build.json @home-assistant/supervisor /tests/components/local_calendar/ @allenporter /homeassistant/components/local_ip/ @issacg /tests/components/local_ip/ @issacg +/homeassistant/components/local_todo/ @allenporter +/tests/components/local_todo/ @allenporter /homeassistant/components/lock/ @home-assistant/core /tests/components/lock/ @home-assistant/core /homeassistant/components/logbook/ @home-assistant/core @@ -755,8 +759,8 @@ build.json @home-assistant/supervisor /tests/components/melissa/ @kennedyshead /homeassistant/components/melnor/ @vanstinator /tests/components/melnor/ @vanstinator -/homeassistant/components/met/ @danielhiversen @thimic -/tests/components/met/ @danielhiversen @thimic +/homeassistant/components/met/ @danielhiversen +/tests/components/met/ @danielhiversen /homeassistant/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame @@ -949,6 +953,8 @@ build.json @home-assistant/supervisor /tests/components/picnic/ @corneyl /homeassistant/components/pilight/ @trekky12 /tests/components/pilight/ @trekky12 +/homeassistant/components/ping/ @jpbede +/tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan /tests/components/plaato/ @JohNan /homeassistant/components/plex/ @jjlawren @@ -1033,6 +1039,8 @@ build.json @home-assistant/supervisor /tests/components/recollect_waste/ @bachya /homeassistant/components/recorder/ @home-assistant/core /tests/components/recorder/ @home-assistant/core +/homeassistant/components/recovery_mode/ @home-assistant/core +/tests/components/recovery_mode/ @home-assistant/core /homeassistant/components/rejseplanen/ @DarkFox /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core @@ -1063,8 +1071,8 @@ build.json @home-assistant/supervisor /tests/components/roborock/ @humbertogontijo @Lash-L /homeassistant/components/roku/ @ctalkington /tests/components/roku/ @ctalkington -/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn -/tests/components/roomba/ @pschmitt @cyr-ius @shenxn +/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 +/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni /homeassistant/components/rpi_power/ @shenxn @swetoast @@ -1083,8 +1091,6 @@ build.json @home-assistant/supervisor /tests/components/rympro/ @OnFreund @elad-bar @maorcc /homeassistant/components/sabnzbd/ @shaiu /tests/components/sabnzbd/ @shaiu -/homeassistant/components/safe_mode/ @home-assistant/core -/tests/components/safe_mode/ @home-assistant/core /homeassistant/components/saj/ @fredericvl /homeassistant/components/samsungtv/ @chemelli74 @epenet /tests/components/samsungtv/ @chemelli74 @epenet @@ -1189,8 +1195,8 @@ build.json @home-assistant/supervisor /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn /tests/components/songpal/ @rytilahti @shenxn -/homeassistant/components/sonos/ @cgtobi @jjlawren -/tests/components/sonos/ @cgtobi @jjlawren +/homeassistant/components/sonos/ @jjlawren +/tests/components/sonos/ @jjlawren /homeassistant/components/soundtouch/ @kroimon /tests/components/soundtouch/ @kroimon /homeassistant/components/spaceapi/ @fabaff @@ -1265,6 +1271,8 @@ build.json @home-assistant/supervisor /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck /tests/components/tailscale/ @frenck +/homeassistant/components/tami4/ @Guy293 +/tests/components/tami4/ @Guy293 /homeassistant/components/tankerkoenig/ @guillempages @mib1185 /tests/components/tankerkoenig/ @guillempages @mib1185 /homeassistant/components/tapsaff/ @bazwilliams @@ -1299,6 +1307,8 @@ build.json @home-assistant/supervisor /homeassistant/components/time_date/ @fabaff /tests/components/time_date/ @fabaff /homeassistant/components/tmb/ @alemuro +/homeassistant/components/todo/ @home-assistant/core +/tests/components/todo/ @home-assistant/core /homeassistant/components/todoist/ @boralyl /tests/components/todoist/ @boralyl /homeassistant/components/tolo/ @MatthiasLohr @@ -1321,10 +1331,10 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_camera/ @gjohansson-ST /homeassistant/components/trafikverket_ferry/ @gjohansson-ST /tests/components/trafikverket_ferry/ @gjohansson-ST -/homeassistant/components/trafikverket_train/ @endor-force @gjohansson-ST -/tests/components/trafikverket_train/ @endor-force @gjohansson-ST -/homeassistant/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST -/tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST +/homeassistant/components/trafikverket_train/ @gjohansson-ST +/tests/components/trafikverket_train/ @gjohansson-ST +/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST +/tests/components/trafikverket_weatherstation/ @gjohansson-ST /homeassistant/components/transmission/ @engrbm87 @JPHutchins /tests/components/transmission/ @engrbm87 @JPHutchins /homeassistant/components/trend/ @jpbede @@ -1436,8 +1446,8 @@ build.json @home-assistant/supervisor /homeassistant/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj /homeassistant/components/wirelesstag/ @sergeymaysak -/homeassistant/components/withings/ @vangorra @joostlek -/tests/components/withings/ @vangorra @joostlek +/homeassistant/components/withings/ @joostlek +/tests/components/withings/ @joostlek /homeassistant/components/wiz/ @sbidy /tests/components/wiz/ @sbidy /homeassistant/components/wled/ @frenck diff --git a/Dockerfile b/Dockerfile index f2a365b2b8a..b61e1461c52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,41 +14,29 @@ COPY requirements.txt homeassistant/ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ RUN \ pip3 install \ - --no-cache-dir \ --only-binary=:all: \ - --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ RUN \ if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \ - pip3 install \ - --no-cache-dir \ - --no-index \ - homeassistant/home_assistant_frontend-*.whl; \ + pip3 install homeassistant/home_assistant_frontend-*.whl; \ fi \ && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ - pip3 install \ - --no-cache-dir \ - --no-index \ - homeassistant/home_assistant_intents-*.whl; \ + pip3 install homeassistant/home_assistant_intents-*.whl; \ fi \ && \ LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ pip3 install \ - --no-cache-dir \ --only-binary=:all: \ - --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core COPY . homeassistant/ RUN \ pip3 install \ - --no-cache-dir \ --only-binary=:all: \ - --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant diff --git a/build.yaml b/build.yaml index f9e19f89e23..813676de3a7 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.09.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.09.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.09.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.09.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.09.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 69893c43847..00000000000 --- a/docs/Makefile +++ /dev/null @@ -1,230 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source - -.PHONY: help -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " livehtml to make standalone HTML files via sphinx-autobuild" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " epub3 to make an epub3" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - @echo " dummy to check syntax errors of document sources" - -.PHONY: clean -clean: - rm -rf $(BUILDDIR)/* - -.PHONY: html -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -.PHONY: livehtml -livehtml: - sphinx-autobuild -z ../homeassistant/ --port 0 -B -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - -.PHONY: dirhtml -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -.PHONY: singlehtml -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -.PHONY: pickle -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -.PHONY: json -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -.PHONY: htmlhelp -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -.PHONY: qthelp -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Home-Assistant.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Home-Assistant.qhc" - -.PHONY: applehelp -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -.PHONY: devhelp -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/Home-Assistant" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Home-Assistant" - @echo "# devhelp" - -.PHONY: epub -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -.PHONY: epub3 -epub3: - $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 - @echo - @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." - -.PHONY: latex -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -.PHONY: latexpdf -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: latexpdfja -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: text -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -.PHONY: man -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -.PHONY: texinfo -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -.PHONY: info -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -.PHONY: gettext -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -.PHONY: changes -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -.PHONY: linkcheck -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -.PHONY: doctest -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -.PHONY: coverage -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -.PHONY: xml -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -.PHONY: pseudoxml -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." - -.PHONY: dummy -dummy: - $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy - @echo - @echo "Build finished. Dummy builder generates no files." diff --git a/docs/build/.empty b/docs/build/.empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 7713f1cadb0..00000000000 --- a/docs/make.bat +++ /dev/null @@ -1,281 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source -set I18NSPHINXOPTS=%SPHINXOPTS% source -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. epub3 to make an epub3 - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - echo. dummy to check syntax errors of document sources - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 1>NUL 2>NUL -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Home-Assistant.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Home-Assistant.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "epub3" ( - %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -if "%1" == "dummy" ( - %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. Dummy builder generates no files. - goto end -) - -:end diff --git a/docs/screenshot-integrations.png b/docs/screenshot-integrations.png deleted file mode 100644 index f169e4486a6..00000000000 Binary files a/docs/screenshot-integrations.png and /dev/null differ diff --git a/docs/screenshots.png b/docs/screenshots.png deleted file mode 100644 index 1305cddbb9d..00000000000 Binary files a/docs/screenshots.png and /dev/null differ diff --git a/docs/source/_ext/edit_on_github.py b/docs/source/_ext/edit_on_github.py deleted file mode 100644 index ecaea5450c9..00000000000 --- a/docs/source/_ext/edit_on_github.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Sphinx extension for ReadTheDocs-style "Edit on GitHub" links on the sidebar. - -Loosely based on https://github.com/astropy/astropy/pull/347 -""" - -import os -import warnings - -__licence__ = "BSD (3 clause)" - - -def get_github_url(app, view, path): - """Build the GitHub URL.""" - return ( - f"https://github.com/{app.config.edit_on_github_project}/" - f"{view}/{app.config.edit_on_github_branch}/" - f"{app.config.edit_on_github_src_path}{path}" - ) - - -def html_page_context(app, pagename, templatename, context, doctree): - """Build the HTML page.""" - if templatename != "page.html": - return - - if not app.config.edit_on_github_project: - warnings.warn("edit_on_github_project not specified") - return - if not doctree: - warnings.warn("doctree is None") - return - path = os.path.relpath(doctree.get("source"), app.builder.srcdir) - show_url = get_github_url(app, "blob", path) - edit_url = get_github_url(app, "edit", path) - - context["show_on_github_url"] = show_url - context["edit_on_github_url"] = edit_url - - -def setup(app): - """Set up the app.""" - app.add_config_value("edit_on_github_project", "", True) - app.add_config_value("edit_on_github_branch", "master", True) - app.add_config_value("edit_on_github_src_path", "", True) # 'eg' "docs/" - app.connect("html-page-context", html_page_context) diff --git a/docs/source/_static/favicon.ico b/docs/source/_static/favicon.ico deleted file mode 100644 index 6d12158c18b..00000000000 Binary files a/docs/source/_static/favicon.ico and /dev/null differ diff --git a/docs/source/_static/logo-apple.png b/docs/source/_static/logo-apple.png deleted file mode 100644 index 03b5dd7780c..00000000000 Binary files a/docs/source/_static/logo-apple.png and /dev/null differ diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png deleted file mode 100644 index 3cd8005a166..00000000000 Binary files a/docs/source/_static/logo.png and /dev/null differ diff --git a/docs/source/_templates/links.html b/docs/source/_templates/links.html deleted file mode 100644 index 7982649f72e..00000000000 --- a/docs/source/_templates/links.html +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/docs/source/_templates/sourcelink.html b/docs/source/_templates/sourcelink.html deleted file mode 100644 index 8cf2c4f92ae..00000000000 --- a/docs/source/_templates/sourcelink.html +++ /dev/null @@ -1,13 +0,0 @@ -{%- if show_source and has_source and sourcename %} -

{{ _('This Page') }}

- -{%- endif %} diff --git a/docs/source/api/auth.rst b/docs/source/api/auth.rst deleted file mode 100644 index 16a1dc69b6b..00000000000 --- a/docs/source/api/auth.rst +++ /dev/null @@ -1,29 +0,0 @@ -:mod:`homeassistant.auth` -========================= - -.. automodule:: homeassistant.auth - :members: - -homeassistant.auth.auth\_store ------------------------------- - -.. automodule:: homeassistant.auth.auth_store - :members: - :undoc-members: - :show-inheritance: - -homeassistant.auth.const ------------------------- - -.. automodule:: homeassistant.auth.const - :members: - :undoc-members: - :show-inheritance: - -homeassistant.auth.models -------------------------- - -.. automodule:: homeassistant.auth.models - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/bootstrap.rst b/docs/source/api/bootstrap.rst deleted file mode 100644 index fdc0b1c731d..00000000000 --- a/docs/source/api/bootstrap.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _bootstrap_module: - -:mod:`homeassistant.bootstrap` ------------------------------- - -.. automodule:: homeassistant.bootstrap - :members: diff --git a/docs/source/api/components.rst b/docs/source/api/components.rst deleted file mode 100644 index a27f93765b4..00000000000 --- a/docs/source/api/components.rst +++ /dev/null @@ -1,170 +0,0 @@ -:mod:`homeassistant.components` -=============================== - -air\_quality --------------------------------------------- - -.. automodule:: homeassistant.components.air_quality - :members: - :undoc-members: - :show-inheritance: - -alarm\_control\_panel --------------------------------------------- - -.. automodule:: homeassistant.components.alarm_control_panel - :members: - :undoc-members: - :show-inheritance: - -binary\_sensor --------------------------------------------- - -.. automodule:: homeassistant.components.binary_sensor - :members: - :undoc-members: - :show-inheritance: - -camera ---------------------------- - -.. automodule:: homeassistant.components.camera - :members: - :undoc-members: - :show-inheritance: - -calendar ---------------------------- - -.. automodule:: homeassistant.components.calendar - :members: - :undoc-members: - :show-inheritance: - -climate ---------------------------- - -.. automodule:: homeassistant.components.climate - :members: - :undoc-members: - :show-inheritance: - -conversation ---------------------------- - -.. automodule:: homeassistant.components.conversation - :members: - :undoc-members: - :show-inheritance: - -cover ---------------------------- - -.. automodule:: homeassistant.components.cover - :members: - :undoc-members: - :show-inheritance: - -device\_tracker ---------------------------- - -.. automodule:: homeassistant.components.device_tracker - :members: - :undoc-members: - :show-inheritance: - -fan ---------------------------- - -.. automodule:: homeassistant.components.fan - :members: - :undoc-members: - :show-inheritance: - -light ---------------------------- - -.. automodule:: homeassistant.components.light - :members: - :undoc-members: - :show-inheritance: - -lock ---------------------------- - -.. automodule:: homeassistant.components.lock - :members: - :undoc-members: - :show-inheritance: - -media\_player ---------------------------- - -.. automodule:: homeassistant.components.media_player - :members: - :undoc-members: - :show-inheritance: - -notify ---------------------------- - -.. automodule:: homeassistant.components.notify - :members: - :undoc-members: - :show-inheritance: - -remote ---------------------------- - -.. automodule:: homeassistant.components.remote - :members: - :undoc-members: - :show-inheritance: - -switch ---------------------------- - -.. automodule:: homeassistant.components.switch - :members: - :undoc-members: - :show-inheritance: - -sensor -------------------------------------- - -.. automodule:: homeassistant.components.sensor - :members: - :undoc-members: - :show-inheritance: - -vacuum -------------------------------------- - -.. automodule:: homeassistant.components.vacuum - :members: - :undoc-members: - :show-inheritance: - -water\_heater -------------------------------------- - -.. automodule:: homeassistant.components.water_heater - :members: - :undoc-members: - :show-inheritance: - -weather ---------------------------- - -.. automodule:: homeassistant.components.weather - :members: - :undoc-members: - :show-inheritance: - -webhook ---------------------------- - -.. automodule:: homeassistant.components.webhook - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/config_entries.rst b/docs/source/api/config_entries.rst deleted file mode 100644 index 4a207b82e16..00000000000 --- a/docs/source/api/config_entries.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _config_entries_module: - -:mod:`homeassistant.config_entries` ------------------------------------ - -.. automodule:: homeassistant.config_entries - :members: diff --git a/docs/source/api/core.rst b/docs/source/api/core.rst deleted file mode 100644 index 7928655b8a1..00000000000 --- a/docs/source/api/core.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _core_module: - -:mod:`homeassistant.core` -------------------------- - -.. automodule:: homeassistant.core - :members: \ No newline at end of file diff --git a/docs/source/api/data_entry_flow.rst b/docs/source/api/data_entry_flow.rst deleted file mode 100644 index 7252780b870..00000000000 --- a/docs/source/api/data_entry_flow.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _data_entry_flow_module: - -:mod:`homeassistant.data_entry_flow` ------------------------------ - -.. automodule:: homeassistant.data_entry_flow - :members: diff --git a/docs/source/api/exceptions.rst b/docs/source/api/exceptions.rst deleted file mode 100644 index e2977c51dae..00000000000 --- a/docs/source/api/exceptions.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _exceptions_module: - -:mod:`homeassistant.exceptions` -------------------------------- - -.. automodule:: homeassistant.exceptions - :members: diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst deleted file mode 100644 index 1b0b529c655..00000000000 --- a/docs/source/api/helpers.rst +++ /dev/null @@ -1,335 +0,0 @@ -:mod:`homeassistant.helpers` -============================ - -.. automodule:: homeassistant.helpers - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.aiohttp\_client -------------------------------------- - -.. automodule:: homeassistant.helpers.aiohttp_client - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.area\_registry ------------------------------------- - -.. automodule:: homeassistant.helpers.area_registry - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.check\_config ------------------------------------ - -.. automodule:: homeassistant.helpers.check_config - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.collection --------------------------------- - -.. automodule:: homeassistant.helpers.collection - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.condition -------------------------------- - -.. automodule:: homeassistant.helpers.condition - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.config\_entry\_flow ------------------------------------------ - -.. automodule:: homeassistant.helpers.config_entry_flow - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.config\_entry\_oauth2\_flow -------------------------------------------------- - -.. automodule:: homeassistant.helpers.config_entry_oauth2_flow - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.config\_validation ----------------------------------------- - -.. automodule:: homeassistant.helpers.config_validation - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.data\_entry\_flow ---------------------------------------- - -.. automodule:: homeassistant.helpers.data_entry_flow - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.debounce ------------------------------- - -.. automodule:: homeassistant.helpers.debounce - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.deprecation ---------------------------------- - -.. automodule:: homeassistant.helpers.deprecation - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.device\_registry --------------------------------------- - -.. automodule:: homeassistant.helpers.device_registry - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.discovery -------------------------------- - -.. automodule:: homeassistant.helpers.discovery - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.dispatcher --------------------------------- - -.. automodule:: homeassistant.helpers.dispatcher - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity ----------------------------- - -.. automodule:: homeassistant.helpers.entity - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity\_component ---------------------------------------- - -.. automodule:: homeassistant.helpers.entity_component - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity\_platform --------------------------------------- - -.. automodule:: homeassistant.helpers.entity_platform - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity\_registry --------------------------------------- - -.. automodule:: homeassistant.helpers.entity_registry - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity\_values ------------------------------------- - -.. automodule:: homeassistant.helpers.entity_values - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entityfilter ----------------------------------- - -.. automodule:: homeassistant.helpers.entityfilter - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.event ---------------------------- - -.. automodule:: homeassistant.helpers.event - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.icon --------------------------- - -.. automodule:: homeassistant.helpers.icon - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.integration\_platform -------------------------------------------- - -.. automodule:: homeassistant.helpers.integration_platform - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.intent ----------------------------- - -.. automodule:: homeassistant.helpers.intent - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.json --------------------------- - -.. automodule:: homeassistant.helpers.json - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.location ------------------------------- - -.. automodule:: homeassistant.helpers.location - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.logging ------------------------------ - -.. automodule:: homeassistant.helpers.logging - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.network ------------------------------ - -.. automodule:: homeassistant.helpers.network - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.restore\_state ------------------------------------- - -.. automodule:: homeassistant.helpers.restore_state - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.script ----------------------------- - -.. automodule:: homeassistant.helpers.script - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.service ------------------------------ - -.. automodule:: homeassistant.helpers.service - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.signal ------------------------------ - -.. automodule:: homeassistant.helpers.signal - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.state ---------------------------- - -.. automodule:: homeassistant.helpers.state - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.storage ------------------------------ - -.. automodule:: homeassistant.helpers.storage - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.sun -------------------------- - -.. automodule:: homeassistant.helpers.sun - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.system\_info ----------------------------------- - -.. automodule:: homeassistant.helpers.system_info - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.temperature ---------------------------------- - -.. automodule:: homeassistant.helpers.temperature - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.template ------------------------------- - -.. automodule:: homeassistant.helpers.template - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.translation ---------------------------------- - -.. automodule:: homeassistant.helpers.translation - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.typing ----------------------------- - -.. automodule:: homeassistant.helpers.typing - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.update\_coordinator ------------------------------------------ - -.. automodule:: homeassistant.helpers.update_coordinator - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/loader.rst b/docs/source/api/loader.rst deleted file mode 100644 index 91594a8a774..00000000000 --- a/docs/source/api/loader.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _loader_module: - -:mod:`homeassistant.loader` ---------------------------- - -.. automodule:: homeassistant.loader - :members: diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst deleted file mode 100644 index 071f4d81cdf..00000000000 --- a/docs/source/api/util.rst +++ /dev/null @@ -1,151 +0,0 @@ -:mod:`homeassistant.util` -========================= - -.. automodule:: homeassistant.util - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.yaml ------------------------ - -.. automodule:: homeassistant.util.yaml - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.aiohttp --------------------------- - -.. automodule:: homeassistant.util.aiohttp - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.async\_ --------------------------- - -.. automodule:: homeassistant.util.async_ - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.color ------------------------- - -.. automodule:: homeassistant.util.color - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.decorator ----------------------------- - -.. automodule:: homeassistant.util.decorator - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.distance ---------------------------- - -.. automodule:: homeassistant.util.distance - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.dt ---------------------- - -.. automodule:: homeassistant.util.dt - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.json ------------------------ - -.. automodule:: homeassistant.util.json - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.location ---------------------------- - -.. automodule:: homeassistant.util.location - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.logging --------------------------- - -.. automodule:: homeassistant.util.logging - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.network --------------------------- - -.. automodule:: homeassistant.util.network - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.package --------------------------- - -.. automodule:: homeassistant.util.package - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.pil ----------------------- - -.. automodule:: homeassistant.util.pil - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.pressure ---------------------------- - -.. automodule:: homeassistant.util.pressure - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.ssl ----------------------- - -.. automodule:: homeassistant.util.ssl - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.temperature ------------------------------- - -.. automodule:: homeassistant.util.temperature - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.unit\_system -------------------------------- - -.. automodule:: homeassistant.util.unit_system - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.volume -------------------------- - -.. automodule:: homeassistant.util.volume - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index 302a0655544..00000000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,438 +0,0 @@ -#!/usr/bin/env python3 -"""Home Assistant documentation build configuration file. - -This file is execfile()d with the current directory set to its -containing dir. - -Note that not all possible configuration values are present in this -autogenerated file. - -All configuration values have a default; values that are commented out -serve to show the default. - -If extensions (or modules to document with autodoc) are in another directory, -add these directories to sys.path here. If the directory is relative to the -documentation root, use os.path.abspath to make it absolute, like shown here. -""" - -import inspect -import os -import sys - -from homeassistant.const import __short_version__, __version__ - -PROJECT_NAME = "Home Assistant" -PROJECT_PACKAGE_NAME = "homeassistant" -PROJECT_AUTHOR = "The Home Assistant Authors" -PROJECT_COPYRIGHT = PROJECT_AUTHOR -PROJECT_LONG_DESCRIPTION = ( - "Home Assistant is an open-source " - "home automation platform running on Python 3. " - "Track and control all devices at home and " - "automate control. " - "Installation in less than a minute." -) -PROJECT_GITHUB_USERNAME = "home-assistant" -PROJECT_GITHUB_REPOSITORY = "home-assistant" - -GITHUB_PATH = f"{PROJECT_GITHUB_USERNAME}/{PROJECT_GITHUB_REPOSITORY}" -GITHUB_URL = f"https://github.com/{GITHUB_PATH}" - - -sys.path.insert(0, os.path.abspath("_ext")) -sys.path.insert(0, os.path.abspath("../homeassistant")) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.linkcode", - "sphinx_autodoc_annotation", - "edit_on_github", -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - -# The encoding of source files. -# -# source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = "index" - -# General information about the project. -project = PROJECT_NAME -copyright = PROJECT_COPYRIGHT -author = PROJECT_AUTHOR - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = __short_version__ -# The full version, including alpha/beta/rc tags. -release = __version__ - -code_branch = "dev" if "dev" in __version__ else "master" - -# Edit on Github config -edit_on_github_project = GITHUB_PATH -edit_on_github_branch = code_branch -edit_on_github_src_path = "docs/source/" - - -def linkcode_resolve(domain, info): - """Determine the URL corresponding to Python object.""" - if domain != "py": - return None - modname = info["module"] - fullname = info["fullname"] - submod = sys.modules.get(modname) - if submod is None: - return None - obj = submod - for part in fullname.split("."): - try: - obj = getattr(obj, part) - except Exception: # pylint: disable=broad-except - return None - try: - fn = inspect.getsourcefile(obj) - except Exception: # pylint: disable=broad-except - fn = None - if not fn: - return None - try: - source, lineno = inspect.findsource(obj) - except Exception: # pylint: disable=broad-except - lineno = None - if lineno: - linespec = "#L%d" % (lineno + 1) - else: - linespec = "" - index = fn.find("/homeassistant/") - if index == -1: - index = 0 - - fn = fn[index:] - - return f"{GITHUB_URL}/blob/{code_branch}/{fn}{linespec}" - - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# -# today = '' -# -# Else, today_fmt is used as the format for a strftime call. -# -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = [] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -# -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "alabaster" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -html_theme_options = { - "logo": "logo.png", - "logo_name": PROJECT_NAME, - "description": PROJECT_LONG_DESCRIPTION, - "github_user": PROJECT_GITHUB_USERNAME, - "github_repo": PROJECT_GITHUB_REPOSITORY, - "github_type": "star", - "github_banner": True, - "touch_icon": "logo-apple.png", - # 'fixed_sidebar': True, # Re-enable when we have more content -} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. -# " v documentation" by default. -# -# html_title = 'Home-Assistant v0.27.0' - -# A shorter title for the navigation bar. Default is the same as html_title. -# -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# -# html_logo = '_static/logo.png' - -# The name of an image file (relative to this directory) to use as a favicon of -# the docs. -# This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# -html_favicon = "_static/favicon.ico" - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# -# html_extra_path = [] - -# If not None, a 'Last updated on:' timestamp is inserted at every page -# bottom, using the given strftime format. -# The empty string is equivalent to '%b %d, %Y'. -# -html_last_updated_fmt = "%b %d, %Y" - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# -html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# -html_sidebars = { - "**": [ - "about.html", - "links.html", - "searchbox.html", - "sourcelink.html", - "navigation.html", - "relations.html", - ] -} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# -# html_additional_pages = {} - -# If false, no module index is generated. -# -# html_domain_indices = True - -# If false, no index is generated. -# -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' -# -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# 'ja' uses this config value. -# 'zh' user can custom change `jieba` dictionary path. -# -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# -# html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = "Home-Assistantdoc" - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ( - master_doc, - "home-assistant.tex", - "Home Assistant Documentation", - "Home Assistant Team", - "manual", - ) -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# -# latex_use_parts = False - -# If true, show page references after internal links. -# -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# -# latex_appendices = [] - -# It false, will not define \strong, \code, itleref, \crossref ... but only -# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added -# packages. -# -# latex_keep_old_macro_names = True - -# If false, no module index is generated. -# -# latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, "home-assistant", "Home Assistant Documentation", [author], 1) -] - -# If true, show URL addresses after external links. -# -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "Home-Assistant", - "Home Assistant Documentation", - author, - "Home Assistant", - "Open-source home automation platform.", - "Miscellaneous", - ) -] - -# Documents to append as an appendix to all manuals. -# -# texinfo_appendices = [] - -# If false, no module index is generated. -# -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# -# texinfo_no_detailmenu = False diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index c592f66c070..00000000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -================================ -Home Assistant API Documentation -================================ - -Public API documentation for `Home Assistant developers`_. - -Contents: - -.. toctree:: - :maxdepth: 2 - :glob: - - api/* - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - -.. _Home Assistant developers: https://developers.home-assistant.io/ diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 9e4afa018a6..4ea324878ec 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -93,7 +93,9 @@ def get_arguments() -> argparse.Namespace: help="Directory that contains the Home Assistant configuration", ) parser.add_argument( - "--safe-mode", action="store_true", help="Start Home Assistant in safe mode" + "--recovery-mode", + action="store_true", + help="Start Home Assistant in recovery mode", ) parser.add_argument( "--debug", action="store_true", help="Start Home Assistant in debug mode" @@ -183,7 +185,9 @@ def main() -> int: ensure_config_path(config_dir) # pylint: disable-next=import-outside-toplevel - from . import runner + from . import config, runner + + safe_mode = config.safe_mode_enabled(config_dir) runtime_conf = runner.RuntimeConfig( config_dir=config_dir, @@ -193,9 +197,10 @@ def main() -> int: log_no_color=args.log_no_color, skip_pip=args.skip_pip, skip_pip_packages=args.skip_pip_packages, - safe_mode=args.safe_mode, + recovery_mode=args.recovery_mode, debug=args.debug, open_ui=args.open_ui, + safe_mode=safe_mode, ) fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 9a537174270..2707f8b6899 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections import OrderedDict from collections.abc import Mapping from datetime import timedelta +import time from typing import Any, cast import jwt @@ -12,7 +13,6 @@ import jwt from homeassistant import data_entry_flow from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.util import dt as dt_util from . import auth_store, jwt_wrapper, models from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN @@ -505,12 +505,13 @@ class AuthManager: self._store.async_log_refresh_token_usage(refresh_token, remote_ip) - now = dt_util.utcnow() + now = int(time.time()) + expire_seconds = int(refresh_token.access_token_expiration.total_seconds()) return jwt.encode( { "iss": refresh_token.id, "iat": now, - "exp": now + refresh_token.access_token_expiration, + "exp": now + expire_seconds, }, refresh_token.jwt_key, algorithm="HS256", diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index b89982127a0..aa28710d8c6 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -50,7 +50,7 @@ class MultiFactorAuthModule: Default is same as type """ - return self.config.get(CONF_ID, self.type) + return self.config.get(CONF_ID, self.type) # type: ignore[no-any-return] @property def type(self) -> str: @@ -60,7 +60,7 @@ class MultiFactorAuthModule: @property def name(self) -> str: """Return the name of the auth module.""" - return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) # type: ignore[no-any-return] # Implement by extending class diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/auth/permissions/events.py similarity index 70% rename from homeassistant/components/websocket_api/permissions.py rename to homeassistant/auth/permissions/events.py index f3a0cebe51f..aec23331664 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/auth/permissions/events.py @@ -1,32 +1,25 @@ -"""Permission constants for the websocket API. - -Separate file to avoid circular imports. -""" +"""Permission for events.""" from __future__ import annotations from typing import Final -from homeassistant.components.frontend import EVENT_PANELS_UPDATED -from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED -from homeassistant.components.persistent_notification import ( - EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, -) -from homeassistant.components.recorder import ( - EVENT_RECORDER_5MIN_STATISTICS_GENERATED, - EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, -) -from homeassistant.components.shopping_list import EVENT_SHOPPING_LIST_UPDATED from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_CORE_CONFIG_UPDATE, + EVENT_LOVELACE_UPDATED, + EVENT_PANELS_UPDATED, + EVENT_RECORDER_5MIN_STATISTICS_GENERATED, + EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, + EVENT_SHOPPING_LIST_UPDATED, EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, ) from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED # These are events that do not contain any sensitive data # Except for state_changed, which is handled accordingly. @@ -36,9 +29,9 @@ SUBSCRIBE_ALLOWLIST: Final[set[str]] = { EVENT_CORE_CONFIG_UPDATE, EVENT_DEVICE_REGISTRY_UPDATED, EVENT_ENTITY_REGISTRY_UPDATED, + EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, EVENT_LOVELACE_UPDATED, EVENT_PANELS_UPDATED, - EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, EVENT_SERVICE_REGISTERED, diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py index 7a1f102fdf3..402d43b7ab7 100644 --- a/homeassistant/auth/permissions/util.py +++ b/homeassistant/auth/permissions/util.py @@ -109,4 +109,4 @@ def test_all(policy: CategoryType, key: str) -> bool: if not isinstance(all_policy, dict): return bool(all_policy) - return all_policy.get(key, False) + return all_policy.get(key, False) # type: ignore[no-any-return] diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 64d25a813ba..7d74dd2dc26 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -67,7 +67,7 @@ class AuthProvider: @property def name(self) -> str: """Return the name of the auth provider.""" - return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) # type: ignore[no-any-return] @property def support_mfa(self) -> bool: diff --git a/homeassistant/backports/LICENSE.Python b/homeassistant/backports/LICENSE.Python new file mode 100644 index 00000000000..f26bcf4d2de --- /dev/null +++ b/homeassistant/backports/LICENSE.Python @@ -0,0 +1,279 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see https://opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +Python software and documentation are licensed under the +Python Software Foundation License Version 2. + +Starting with Python 3.8.6, examples, recipes, and other code in +the documentation are dual licensed under the PSF License Version 2 +and the Zero-Clause BSD license. + +Some software incorporated into Python is under different licenses. +The licenses are listed with code falling under that license. + + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION +---------------------------------------------------------------------- + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/homeassistant/backports/README b/homeassistant/backports/README new file mode 100644 index 00000000000..9cc28864264 --- /dev/null +++ b/homeassistant/backports/README @@ -0,0 +1,5 @@ +This package contains backports of Python functionality from future Python +versions. + +Some of the backports have been copied directly from the CPython project, +and are subject to license agreement as detailed in LICENSE.Python. diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index 212c8516b48..6271bb87d14 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -1,4 +1,14 @@ """Functools backports from standard lib.""" + +# This file contains parts of Python's module wrapper +# for the _functools C module +# to allow utilities written in Python to be added +# to the functools module. +# Written by Nick Coghlan , +# Raymond Hettinger , +# and Łukasz Langa . +# Copyright © 2001-2023 Python Software Foundation; All Rights Reserved + from __future__ import annotations from collections.abc import Callable @@ -68,4 +78,4 @@ class cached_property(Generic[_T]): raise TypeError(msg) from None return val - __class_getitem__ = classmethod(GenericAlias) + __class_getitem__ = classmethod(GenericAlias) # type: ignore[var-annotated] diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 81ae4eb6e18..098f970d55f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -120,6 +120,7 @@ async def async_setup_hass( runtime_config.log_no_color, ) + hass.config.safe_mode = runtime_config.safe_mode hass.config.skip_pip = runtime_config.skip_pip hass.config.skip_pip_packages = runtime_config.skip_pip_packages if runtime_config.skip_pip or runtime_config.skip_pip_packages: @@ -137,14 +138,14 @@ async def async_setup_hass( config_dict = None basic_setup_success = False - if not (safe_mode := runtime_config.safe_mode): + if not (recovery_mode := runtime_config.recovery_mode): await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) try: config_dict = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: _LOGGER.error( - "Failed to parse configuration.yaml: %s. Activating safe mode", + "Failed to parse configuration.yaml: %s. Activating recovery mode", err, ) else: @@ -156,24 +157,24 @@ async def async_setup_hass( ) if config_dict is None: - safe_mode = True + recovery_mode = True elif not basic_setup_success: - _LOGGER.warning("Unable to set up core integrations. Activating safe mode") - safe_mode = True + _LOGGER.warning("Unable to set up core integrations. Activating recovery mode") + recovery_mode = True elif ( "frontend" in hass.data.get(DATA_SETUP, {}) and "frontend" not in hass.config.components ): - _LOGGER.warning("Detected that frontend did not load. Activating safe mode") + _LOGGER.warning("Detected that frontend did not load. Activating recovery mode") # Ask integrations to shut down. It's messy but we can't # do a clean stop without knowing what is broken with contextlib.suppress(asyncio.TimeoutError): async with hass.timeout.async_timeout(10): await hass.async_stop() - safe_mode = True + recovery_mode = True old_config = hass.config old_logging = hass.data.get(DATA_LOGGING) @@ -187,16 +188,18 @@ async def async_setup_hass( # Setup loader cache after the config dir has been set loader.async_setup(hass) - if safe_mode: - _LOGGER.info("Starting in safe mode") - hass.config.safe_mode = True + if recovery_mode: + _LOGGER.info("Starting in recovery mode") + hass.config.recovery_mode = True http_conf = (await http.async_get_last_config(hass)) or {} await async_from_config_dict( - {"safe_mode": {}, "http": http_conf}, + {"recovery_mode": {}, "http": http_conf}, hass, ) + elif hass.config.safe_mode: + _LOGGER.info("Starting in safe mode") if runtime_config.open_ui: hass.add_job(open_hass_ui, hass) @@ -471,7 +474,7 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} # Add config entry domains - if not hass.config.safe_mode: + if not hass.config.recovery_mode: domains.update(hass.config_entries.async_domains()) # Make sure the Hass.io component is loaded diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index ce71457a656..7c6ebc044e9 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -11,6 +11,7 @@ "google_maps", "google_pubsub", "google_sheets", + "google_tasks", "google_translate", "google_travel_time", "google_wifi", diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 490561c7485..4e4b6a9561d 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -1,6 +1,7 @@ """Support for the Abode Security System.""" from __future__ import annotations +from dataclasses import dataclass, field from functools import partial from jaraco.abode.automation import Automation as AbodeAuto @@ -25,7 +26,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, entity from homeassistant.helpers.device_registry import DeviceInfo @@ -71,15 +72,14 @@ PLATFORMS = [ ] +@dataclass class AbodeSystem: """Abode System class.""" - def __init__(self, abode: Abode, polling: bool) -> None: - """Initialize the system.""" - self.abode = abode - self.polling = polling - self.entity_ids: set[str | None] = set() - self.logout_listener = None + abode: Abode + polling: bool + entity_ids: set[str | None] = field(default_factory=set) + logout_listener: CALLBACK_TYPE | None = None async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 3a834261af5..5a5a1de2a01 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==1.0.0"] + "requirements": ["accuweather==2.0.0"] } diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index a8d61746292..65cffc509d5 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/adax", "iot_class": "local_polling", "loggers": ["adax", "adax_local"], - "requirements": ["adax==0.2.0", "Adax-local==0.1.5"] + "requirements": ["adax==0.3.0", "Adax-local==0.1.5"] } diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json index 36973ae96ab..24e1283e9df 100644 --- a/homeassistant/components/adguard/manifest.json +++ b/homeassistant/components/adguard/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["adguardhome"], - "requirements": ["adguardhome==0.6.1"] + "requirements": ["adguardhome==0.6.2"] } diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index cda123f62ee..a4e0a1033ba 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -127,7 +127,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """Return the current target temperature.""" # If the system is in MyZone mode, and a zone is set, return that temperature instead. if ( - self._ac["myZone"] > 0 + self._myzone and not self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED) and not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED) ): diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index b300a677793..691db99769b 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -63,7 +63,7 @@ class AdvantageAirAcEntity(AdvantageAirEntity): return self.coordinator.data["aircons"][self.ac_key]["info"] @property - def _myzone(self) -> dict[str, Any]: + def _myzone(self) -> dict[str, Any] | None: return self.coordinator.data["aircons"][self.ac_key]["zones"].get( f"z{self._ac['myZone']:02}" ) diff --git a/homeassistant/components/airnow/const.py b/homeassistant/components/airnow/const.py index 34b1f4392bc..137c8f1efad 100644 --- a/homeassistant/components/airnow/const.py +++ b/homeassistant/components/airnow/const.py @@ -9,8 +9,9 @@ ATTR_API_CAT_DESCRIPTION = "Name" ATTR_API_O3 = "O3" ATTR_API_PM25 = "PM2.5" ATTR_API_POLLUTANT = "Pollutant" -ATTR_API_REPORT_DATE = "HourObserved" -ATTR_API_REPORT_HOUR = "DateObserved" +ATTR_API_REPORT_DATE = "DateObserved" +ATTR_API_REPORT_HOUR = "HourObserved" +ATTR_API_REPORT_TZ = "LocalTimeZone" ATTR_API_STATE = "StateCode" ATTR_API_STATION = "ReportingArea" ATTR_API_STATION_LATITUDE = "Latitude" diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 7a4ad46cd82..e89afc2f7ce 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -20,6 +20,7 @@ from .const import ( ATTR_API_POLLUTANT, ATTR_API_REPORT_DATE, ATTR_API_REPORT_HOUR, + ATTR_API_REPORT_TZ, ATTR_API_STATE, ATTR_API_STATION, ATTR_API_STATION_LATITUDE, @@ -83,6 +84,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator): # Copy Report Details data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] + data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ] # Copy Station Details data[ATTR_API_STATE] = obv[ATTR_API_STATE] diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index c83232c273a..f9d35d50810 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import Any from homeassistant.components.sensor import ( @@ -13,6 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_TIME, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, ) @@ -21,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import get_time_zone from . import AirNowDataUpdateCoordinator from .const import ( @@ -29,6 +32,9 @@ from .const import ( ATTR_API_AQI_LEVEL, ATTR_API_O3, ATTR_API_PM25, + ATTR_API_REPORT_DATE, + ATTR_API_REPORT_HOUR, + ATTR_API_REPORT_TZ, ATTR_API_STATION, ATTR_API_STATION_LATITUDE, ATTR_API_STATION_LONGITUDE, @@ -78,6 +84,12 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( extra_state_attributes_fn=lambda data: { ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION], ATTR_LEVEL: data[ATTR_API_AQI_LEVEL], + ATTR_TIME: datetime.strptime( + f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}", + "%Y-%m-%d %H", + ) + .replace(tzinfo=get_time_zone(data[ATTR_API_REPORT_TZ])) + .isoformat(), }, ), AirNowEntityDescription( diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 28b5fa3a7a6..aaeb91cf30b 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -226,6 +226,14 @@ class AirthingsSensor( model=airthings_device.model, ) + @property + def available(self) -> bool: + """Check if device and sensor is available in data.""" + return ( + super().available + and self.entity_description.key in self.coordinator.data.sensors + ) + @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index b1159e6f251..b7343377a2b 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 1403cc94346..e07400f2764 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -421,7 +421,6 @@ class AirVisualEntity(CoordinatorEntity): self._entry = entry self.entity_description = description - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 18393031ae3..1fe5e45ee44 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -7,15 +7,21 @@ from aioairzone_cloud.common import OperationAction, OperationMode, TemperatureU from aioairzone_cloud.const import ( API_MODE, API_OPTS, + API_PARAMS, API_POWER, API_SETPOINT, API_UNITS, API_VALUE, AZD_ACTION, + AZD_AIDOOS, + AZD_GROUPS, AZD_HUMIDITY, + AZD_INSTALLATIONS, AZD_MASTER, AZD_MODE, AZD_MODES, + AZD_NUM_DEVICES, + AZD_NUM_GROUPS, AZD_POWER, AZD_TEMP, AZD_TEMP_SET, @@ -39,7 +45,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneEntity, AirzoneZoneEntity +from .entity import ( + AirzoneAidooEntity, + AirzoneEntity, + AirzoneGroupEntity, + AirzoneInstallationEntity, + AirzoneZoneEntity, +) HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = { OperationAction.COOLING: HVACAction.COOLING, @@ -82,6 +94,38 @@ async def async_setup_entry( entities: list[AirzoneClimate] = [] + # Aidoos + for aidoo_id, aidoo_data in coordinator.data.get(AZD_AIDOOS, {}).items(): + entities.append( + AirzoneAidooClimate( + coordinator, + aidoo_id, + aidoo_data, + ) + ) + + # Groups + for group_id, group_data in coordinator.data.get(AZD_GROUPS, {}).items(): + if group_data[AZD_NUM_DEVICES] > 1: + entities.append( + AirzoneGroupClimate( + coordinator, + group_id, + group_data, + ) + ) + + # Installations + for inst_id, inst_data in coordinator.data.get(AZD_INSTALLATIONS, {}).items(): + if inst_data[AZD_NUM_GROUPS] > 1: + entities.append( + AirzoneInstallationClimate( + coordinator, + inst_id, + inst_data, + ) + ) + # Zones for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): entities.append( @@ -98,9 +142,39 @@ async def async_setup_entry( class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" + _attr_has_entity_name = True + _attr_name = None _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update climate attributes.""" + self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) + self._attr_current_humidity = self.get_airzone_value(AZD_HUMIDITY) + self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ + self.get_airzone_value(AZD_ACTION) + ] + if self.get_airzone_value(AZD_POWER): + self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ + self.get_airzone_value(AZD_MODE) + ] + else: + self._attr_hvac_mode = HVACMode.OFF + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_SET_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_SET_MIN) + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + + +class AirzoneDeviceClimate(AirzoneClimate): + """Define an Airzone Cloud Device base class.""" + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { @@ -131,36 +205,143 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): } await self._async_update_params(params) - @callback - def _handle_coordinator_update(self) -> None: - """Update attributes when the coordinator updates.""" - self._async_update_attrs() - super()._handle_coordinator_update() - @callback - def _async_update_attrs(self) -> None: - """Update climate attributes.""" - self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) - self._attr_current_humidity = self.get_airzone_value(AZD_HUMIDITY) - self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ - self.get_airzone_value(AZD_ACTION) - ] - if self.get_airzone_value(AZD_POWER): - self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ - self.get_airzone_value(AZD_MODE) - ] +class AirzoneDeviceGroupClimate(AirzoneClimate): + """Define an Airzone Cloud DeviceGroup base class.""" + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + params = { + API_PARAMS: { + API_POWER: True, + }, + } + await self._async_update_params(params) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + params = { + API_PARAMS: { + API_POWER: False, + }, + } + await self._async_update_params(params) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + params: dict[str, Any] = {} + if ATTR_TEMPERATURE in kwargs: + params[API_PARAMS] = { + API_SETPOINT: kwargs[ATTR_TEMPERATURE], + } + params[API_OPTS] = { + API_UNITS: TemperatureUnit.CELSIUS.value, + } + await self._async_update_params(params) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + params: dict[str, Any] = { + API_PARAMS: {}, + } + if hvac_mode == HVACMode.OFF: + params[API_PARAMS][API_POWER] = False else: - self._attr_hvac_mode = HVACMode.OFF - self._attr_max_temp = self.get_airzone_value(AZD_TEMP_SET_MAX) - self._attr_min_temp = self.get_airzone_value(AZD_TEMP_SET_MIN) - self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + params[API_PARAMS][API_MODE] = mode.value + params[API_PARAMS][API_POWER] = True + await self._async_update_params(params) -class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneClimate): +class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneDeviceClimate): + """Define an Airzone Cloud Aidoo climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + aidoo_id: str, + aidoo_data: dict, + ) -> None: + """Initialize Airzone Cloud Aidoo climate.""" + super().__init__(coordinator, aidoo_id, aidoo_data) + + self._attr_unique_id = aidoo_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + params: dict[str, Any] = {} + if hvac_mode == HVACMode.OFF: + params[API_POWER] = { + API_VALUE: False, + } + else: + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + params[API_MODE] = { + API_VALUE: mode.value, + } + params[API_POWER] = { + API_VALUE: True, + } + await self._async_update_params(params) + + +class AirzoneGroupClimate(AirzoneGroupEntity, AirzoneDeviceGroupClimate): + """Define an Airzone Cloud Group climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + group_id: str, + group_data: dict, + ) -> None: + """Initialize Airzone Cloud Group climate.""" + super().__init__(coordinator, group_id, group_data) + + self._attr_unique_id = group_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + +class AirzoneInstallationClimate(AirzoneInstallationEntity, AirzoneDeviceGroupClimate): + """Define an Airzone Cloud Installation climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + inst_id: str, + inst_data: dict, + ) -> None: + """Initialize Airzone Cloud Installation climate.""" + super().__init__(coordinator, inst_id, inst_data) + + self._attr_unique_id = inst_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + +class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneDeviceClimate): """Define an Airzone Cloud Zone climate.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -191,7 +372,8 @@ class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneClimate): } else: mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] - if mode != self.get_airzone_value(AZD_MODE): + cur_mode = self.get_airzone_value(AZD_MODE) + if hvac_mode != HVAC_MODE_LIB_TO_HASS[cur_mode]: if self.get_airzone_value(AZD_MASTER): params[API_MODE] = { API_VALUE: mode.value, diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 3214869aaab..d5dd0cfcfb4 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -9,6 +9,8 @@ from aioairzone_cloud.const import ( AZD_AIDOOS, AZD_AVAILABLE, AZD_FIRMWARE, + AZD_GROUPS, + AZD_INSTALLATIONS, AZD_NAME, AZD_SYSTEM_ID, AZD_SYSTEMS, @@ -74,6 +76,104 @@ class AirzoneAidooEntity(AirzoneEntity): value = aidoo.get(key) return value + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Aidoo parameters to Cloud API.""" + _LOGGER.debug("aidoo=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_aidoo_id_params( + self.aidoo_id, params + ) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + + +class AirzoneGroupEntity(AirzoneEntity): + """Define an Airzone Cloud Group entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + group_id: str, + group_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.group_id = group_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, group_id)}, + manufacturer=MANUFACTURER, + name=group_data[AZD_NAME], + ) + + def get_airzone_value(self, key: str) -> Any: + """Return Group value by key.""" + value = None + if group := self.coordinator.data[AZD_GROUPS].get(self.group_id): + value = group.get(key) + return value + + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Group parameters to Cloud API.""" + _LOGGER.debug("group=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_group_id_params( + self.group_id, params + ) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + + +class AirzoneInstallationEntity(AirzoneEntity): + """Define an Airzone Cloud Installation entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + inst_id: str, + inst_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.inst_id = inst_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, inst_id)}, + manufacturer=MANUFACTURER, + name=inst_data[AZD_NAME], + ) + + def get_airzone_value(self, key: str) -> Any: + """Return Installation value by key.""" + value = None + if inst := self.coordinator.data[AZD_INSTALLATIONS].get(self.inst_id): + value = inst.get(key) + return value + + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Installation parameters to Cloud API.""" + _LOGGER.debug("installation=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_installation_id_params( + self.inst_id, params + ) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + class AirzoneSystemEntity(AirzoneEntity): """Define an Airzone Cloud System entity.""" diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 1a158fcd1fe..eb959342122 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.3"] + "requirements": ["aioairzone-cloud==0.3.1"] } diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index a7065a38686..cde90e127f3 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -580,8 +580,8 @@ class AlexaBrightnessController(AlexaCapability): """Read and return a property.""" if name != "brightness": raise UnsupportedProperty(name) - if "brightness" in self.entity.attributes: - return round(self.entity.attributes["brightness"] / 255.0 * 100) + if brightness := self.entity.attributes.get("brightness"): + return round(brightness / 255.0 * 100) return 0 @@ -630,12 +630,16 @@ class AlexaColorController(AlexaCapability): if name != "color": raise UnsupportedProperty(name) - hue, saturation = self.entity.attributes.get(light.ATTR_HS_COLOR, (0, 0)) + hue_saturation: tuple[float, float] | None + if (hue_saturation := self.entity.attributes.get(light.ATTR_HS_COLOR)) is None: + hue_saturation = (0, 0) + if (brightness := self.entity.attributes.get(light.ATTR_BRIGHTNESS)) is None: + brightness = 0 return { - "hue": hue, - "saturation": saturation / 100.0, - "brightness": self.entity.attributes.get(light.ATTR_BRIGHTNESS, 0) / 255.0, + "hue": hue_saturation[0], + "saturation": hue_saturation[1] / 100.0, + "brightness": brightness / 255.0, } @@ -683,10 +687,8 @@ class AlexaColorTemperatureController(AlexaCapability): """Read and return a property.""" if name != "colorTemperatureInKelvin": raise UnsupportedProperty(name) - if "color_temp" in self.entity.attributes: - return color_util.color_temperature_mired_to_kelvin( - self.entity.attributes["color_temp"] - ) + if color_temp := self.entity.attributes.get("color_temp"): + return color_util.color_temperature_mired_to_kelvin(color_temp) return None diff --git a/homeassistant/components/android_ip_webcam/camera.py b/homeassistant/components/android_ip_webcam/camera.py index 92ff29177dd..a12798a5b91 100644 --- a/homeassistant/components/android_ip_webcam/camera.py +++ b/homeassistant/components/android_ip_webcam/camera.py @@ -5,7 +5,6 @@ from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, - CONF_NAME, CONF_PASSWORD, CONF_USERNAME, HTTP_BASIC_AUTHENTICATION, @@ -39,14 +38,7 @@ class IPWebcamCamera(MjpegCamera): def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None: """Initialize the camera.""" - name = None - # keep imported name until YAML is removed - if CONF_NAME in coordinator.config_entry.data: - name = coordinator.config_entry.data[CONF_NAME] - self._attr_has_entity_name = False - super().__init__( - name=name, mjpeg_url=coordinator.cam.mjpeg_url, still_image_url=coordinator.cam.image_url, authentication=HTTP_BASIC_AUTHENTICATION, @@ -56,5 +48,5 @@ class IPWebcamCamera(MjpegCamera): self._attr_unique_id = f"{coordinator.config_entry.entry_id}-camera" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - name=name or coordinator.config_entry.data[CONF_HOST], + name=coordinator.config_entry.data[CONF_HOST], ) diff --git a/homeassistant/components/android_ip_webcam/entity.py b/homeassistant/components/android_ip_webcam/entity.py index d729da22a9d..e0432ad060c 100644 --- a/homeassistant/components/android_ip_webcam/entity.py +++ b/homeassistant/components/android_ip_webcam/entity.py @@ -19,11 +19,6 @@ class AndroidIPCamBaseEntity(CoordinatorEntity[AndroidIPCamDataUpdateCoordinator ) -> None: """Initialize the base entity.""" super().__init__(coordinator) - if CONF_NAME in coordinator.config_entry.data: - # name is legacy imported from YAML config - # this block can be removed when removing import from YAML - self._attr_name = f"{coordinator.config_entry.data[CONF_NAME]} {self.entity_description.name}" - self._attr_has_entity_name = False self.cam = coordinator.cam self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index b8c020e6e1e..2d0b062c750 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -9,7 +9,7 @@ "loggers": ["adb_shell", "androidtv", "pure_python_adb"], "requirements": [ "adb-shell[async]==0.4.4", - "androidtv[async]==0.0.72", + "androidtv[async]==0.0.73", "pure-python-adb[async]==0.3.0.dev0" ] } diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index 38a70b450ab..c974735791e 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -1,8 +1,8 @@ """Support for Apache Kafka.""" from datetime import datetime import json +import sys -from aiokafka import AIOKafkaProducer import voluptuous as vol from homeassistant.const import ( @@ -16,11 +16,16 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType from homeassistant.util import ssl as ssl_util +if sys.version_info < (3, 12): + from aiokafka import AIOKafkaProducer + + DOMAIN = "apache_kafka" CONF_FILTER = "filter" @@ -49,6 +54,10 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate the Apache Kafka integration.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Apache Kafka is not supported on Python 3.12. Please use Python 3.11." + ) conf = config[DOMAIN] kafka = hass.data[DOMAIN] = KafkaManager( diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index a22687c0fb5..1f7ac45372e 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.13.4"], + "requirements": ["pyatv==0.14.3"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index a70a30656f2..dd1f554919e 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -371,11 +371,15 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): @property def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" - if self._playing and self._is_feature_available(FeatureName.Repeat): + if ( + self._playing + and self._is_feature_available(FeatureName.Repeat) + and (repeat := self._playing.repeat) + ): return { RepeatState.Track: RepeatMode.ONE, RepeatState.All: RepeatMode.ALL, - }.get(self._playing.repeat, RepeatMode.OFF) + }.get(repeat, RepeatMode.OFF) return None @property diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index f3be6977891..bab3421c58d 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -21,6 +21,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +COMMAND_TO_ATTRIBUTE = { + "wakeup": ("power", "turn_on"), + "suspend": ("power", "turn_off"), + "turn_on": ("power", "turn_on"), + "turn_off": ("power", "turn_off"), + "volume_up": ("audio", "volume_up"), + "volume_down": ("audio", "volume_down"), + "home_hold": ("remote_control", "home"), +} async def async_setup_entry( @@ -61,7 +70,13 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): for _ in range(num_repeats): for single_command in command: - attr_value = getattr(self.atv.remote_control, single_command, None) + attr_value = None + if attributes := COMMAND_TO_ATTRIBUTE.get(single_command): + attr_value = self.atv + for attr_name in attributes: + attr_value = getattr(attr_value, attr_name, None) + if not attr_value: + attr_value = getattr(self.atv.remote_control, single_command, None) if not attr_value: raise ValueError("Command not found. Exiting sequence") diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index e67192040a6..8132f3623a9 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.5.0"] + "requirements": ["apprise==1.6.0"] } diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index 03dc0995c1c..0d22a0d1859 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -13,11 +13,11 @@ "connectable": false } ], - "codeowners": ["@aschmitz"], + "codeowners": ["@aschmitz", "@thecode"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/aranet", "integration_type": "device", "iot_class": "local_push", - "requirements": ["aranet4==2.1.3"] + "requirements": ["aranet4==2.2.2"] } diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index b47af54a51f..ad11b4bdbdc 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any from aranet4.client import Aranet4Advertisement from bleak.backends.device import BLEDevice @@ -32,6 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -121,22 +123,22 @@ def sensor_update_to_bluetooth_data_update( adv: Aranet4Advertisement, ) -> PassiveBluetoothDataUpdate: """Convert a sensor update to a Bluetooth data update.""" + data: dict[PassiveBluetoothEntityKey, Any] = {} + names: dict[PassiveBluetoothEntityKey, str | None] = {} + descs: dict[PassiveBluetoothEntityKey, EntityDescription] = {} + for key, desc in SENSOR_DESCRIPTIONS.items(): + tag = _device_key_to_bluetooth_entity_key(adv.device, key) + val = getattr(adv.readings, key) + if val == -1: + continue + data[tag] = val + names[tag] = desc.name + descs[tag] = desc return PassiveBluetoothDataUpdate( devices={adv.device.address: _sensor_device_info_to_hass(adv)}, - entity_descriptions={ - _device_key_to_bluetooth_entity_key(adv.device, key): desc - for key, desc in SENSOR_DESCRIPTIONS.items() - }, - entity_data={ - _device_key_to_bluetooth_entity_key(adv.device, key): getattr( - adv.readings, key, None - ) - for key in SENSOR_DESCRIPTIONS - }, - entity_names={ - _device_key_to_bluetooth_entity_key(adv.device, key): desc.name - for key, desc in SENSOR_DESCRIPTIONS.items() - }, + entity_descriptions=descs, + entity_data=data, + entity_names=names, ) diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json index 1970beec210..918cfc1d384 100644 --- a/homeassistant/components/aranet/strings.json +++ b/homeassistant/components/aranet/strings.json @@ -4,7 +4,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index d7e5e330705..14eedd279b8 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -60,7 +60,6 @@ class VariableSensorEntity(AsekoEntity, SensorEntity): self._attr_icon = { "clf": "mdi:flask", - "ph": "mdi:ph", "rx": "mdi:test-tube", "waterLevel": "mdi:waves", "waterTemp": "mdi:coolant-temperature", @@ -69,6 +68,7 @@ class VariableSensorEntity(AsekoEntity, SensorEntity): self._attr_device_class = { "airTemp": SensorDeviceClass.TEMPERATURE, "waterTemp": SensorDeviceClass.TEMPERATURE, + "ph": SensorDeviceClass.PH, }.get(self._variable.type) @property diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index fab4c3178bc..64fe9e1f5f4 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -9,7 +9,7 @@ from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType -from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DOMAIN +from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, DOMAIN from .error import PipelineNotFound from .pipeline import ( AudioSettings, @@ -58,6 +58,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Assist pipeline integration.""" hass.data[DATA_CONFIG] = config.get(DOMAIN, {}) + # wake_word_id -> timestamp of last detection (monotonic_ns) + hass.data[DATA_LAST_WAKE_UP] = {} + await async_setup_pipeline_store(hass) async_register_websocket_api(hass) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 6ec031baf3b..1e1c0b6f495 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -12,11 +12,13 @@ from pathlib import Path from queue import Queue from threading import Thread import time -from typing import Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast import wave import voluptuous as vol -from webrtc_noise_gain import AudioProcessor + +if TYPE_CHECKING: + from webrtc_noise_gain import AudioProcessor from homeassistant.components import ( conversation, @@ -522,6 +524,12 @@ class PipelineRun: # Initialize with audio settings self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES) if self.audio_settings.needs_processor: + # Delay import of webrtc so HA start up is not crashing + # on older architectures (armhf). + # + # pylint: disable=import-outside-toplevel + from webrtc_noise_gain import AudioProcessor + self.audio_processor = AudioProcessor( self.audio_settings.auto_gain_dbfs, self.audio_settings.noise_suppression_level, @@ -681,7 +689,8 @@ class PipelineRun: wake_word_output: dict[str, Any] = {} else: # Avoid duplicate detections by checking cooldown - last_wake_up = self.hass.data.get(DATA_LAST_WAKE_UP) + wake_up_key = f"{self.wake_word_entity_id}.{result.wake_word_id}" + last_wake_up = self.hass.data[DATA_LAST_WAKE_UP].get(wake_up_key) if last_wake_up is not None: sec_since_last_wake_up = time.monotonic() - last_wake_up if sec_since_last_wake_up < wake_word_settings.cooldown_seconds: @@ -689,7 +698,7 @@ class PipelineRun: raise WakeWordDetectionAborted # Record last wake up time to block duplicate detections - self.hass.data[DATA_LAST_WAKE_UP] = time.monotonic() + self.hass.data[DATA_LAST_WAKE_UP][wake_up_key] = time.monotonic() if result.queued_audio: # Add audio that was pending at detection. diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index 30fad1c80d6..9cc5fe9dfc6 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -7,8 +7,6 @@ from dataclasses import dataclass from enum import StrEnum from typing import Final, cast -from webrtc_noise_gain import AudioProcessor - _SAMPLE_RATE: Final = 16000 # Hz _SAMPLE_WIDTH: Final = 2 # bytes @@ -51,6 +49,12 @@ class WebRtcVad(VoiceActivityDetector): def __init__(self) -> None: """Initialize webrtcvad.""" + # Delay import of webrtc so HA start up is not crashing + # on older architectures (armhf). + # + # pylint: disable=import-outside-toplevel + from webrtc_noise_gain import AudioProcessor + # Just VAD: no noise suppression or auto gain self._audio_processor = AudioProcessor(0, 0) diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 3a9abdd7e85..fc0a9ee539e 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -10,6 +10,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_ASUSWRT, DOMAIN from .router import AsusWrtDevInfo, AsusWrtRouter +ATTR_LAST_TIME_REACHABLE = "last_time_reachable" + DEFAULT_DEVICE_NAME = "Unknown device" @@ -52,13 +54,14 @@ def add_entities( class AsusWrtDevice(ScannerEntity): """Representation of a AsusWrt device.""" + _unrecorded_attributes = frozenset({ATTR_LAST_TIME_REACHABLE}) + _attr_should_poll = False def __init__(self, router: AsusWrtRouter, device: AsusWrtDevInfo) -> None: """Initialize a AsusWrt device.""" self._router = router self._device = device - self._attr_unique_id = device.mac self._attr_name = device.name or DEFAULT_DEVICE_NAME @property @@ -98,7 +101,7 @@ class AsusWrtDevice(ScannerEntity): self._attr_extra_state_attributes = {} if self._device.last_activity: self._attr_extra_state_attributes[ - "last_time_reachable" + ATTR_LAST_TIME_REACHABLE ] = self._device.last_activity.isoformat(timespec="seconds") self.async_write_ha_state() diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 408d6e0be7e..c1eb21b6827 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -25,13 +25,14 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import aiohttp_client, device_registry as dr, discovery_flow +from homeassistant.helpers import device_registry as dr, discovery_flow from .activity import ActivityStream from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .gateway import AugustGateway from .subscriber import AugustSubscriberMixin +from .util import async_create_august_clientsession _LOGGER = logging.getLogger(__name__) @@ -46,10 +47,7 @@ YALEXS_BLE_DOMAIN = "yalexs_ble" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up August from a config entry.""" - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - session = aiohttp_client.async_create_clientsession(hass) + session = async_create_august_clientsession(hass) august_gateway = AugustGateway(hass, session) try: diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 670d1608421..0028db55415 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -13,7 +13,6 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -26,6 +25,7 @@ from .const import ( ) from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .gateway import AugustGateway +from .util import async_create_august_clientsession _LOGGER = logging.getLogger(__name__) @@ -159,10 +159,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Set up the gateway.""" if self._august_gateway is not None: return self._august_gateway - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - self._aiohttp_session = aiohttp_client.async_create_clientsession(self.hass) + self._aiohttp_session = async_create_august_clientsession(self.hass) self._august_gateway = AugustGateway(self.hass, self._aiohttp_session) return self._august_gateway diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 2fe7d62ac3d..50df1f4bd1d 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.0"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.1"] } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 75e8cd8984c..7f6e0c51995 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -12,6 +12,7 @@ from yalexs.keypad import KeypadDetail from yalexs.lock import Lock, LockDetail from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -27,7 +28,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from . import AugustData from .const import ( @@ -174,8 +174,7 @@ async def _async_migrate_old_unique_ids(hass, devices): registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): +class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): """Representation of an August lock operation sensor.""" _attr_translation_key = "operator" @@ -247,10 +246,15 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or last_state.state == STATE_UNAVAILABLE: + last_sensor_state = await self.async_get_last_sensor_data() + if ( + not last_state + or not last_sensor_state + or last_state.state == STATE_UNAVAILABLE + ): return - self._attr_native_value = last_state.state + self._attr_native_value = last_sensor_state.native_value if ATTR_ENTITY_PICTURE in last_state.attributes: self._attr_entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] if ATTR_OPERATION_REMOTE in last_state.attributes: diff --git a/homeassistant/components/august/util.py b/homeassistant/components/august/util.py new file mode 100644 index 00000000000..9703fdc6fcd --- /dev/null +++ b/homeassistant/components/august/util.py @@ -0,0 +1,24 @@ +"""August util functions.""" + +import socket + +import aiohttp + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client + + +@callback +def async_create_august_clientsession(hass: HomeAssistant) -> aiohttp.ClientSession: + """Create an aiohttp session for the august integration.""" + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + # + # The family is set to AF_INET because IPv6 keeps coming up as an issue + # see https://github.com/home-assistant/core/issues/97146 + # + # When https://github.com/aio-libs/aiohttp/issues/4451 is implemented + # we can allow IPv6 again + # + return aiohttp_client.async_create_clientsession(hass, family=socket.AF_INET) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index df388e52a7f..84f7f3aca52 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -733,14 +733,14 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self.async_write_ha_state() + def _log_callback(self, level: int, msg: str, **kwargs: Any) -> None: + """Log helper callback.""" + self._logger.log(level, "%s %s", msg, self.name, **kwargs) + async def _async_attach_triggers( self, home_assistant_start: bool ) -> Callable[[], None] | None: """Set up the triggers.""" - - def log_cb(level: int, msg: str, **kwargs: Any) -> None: - self._logger.log(level, "%s %s", msg, self.name, **kwargs) - this = None self.async_write_ha_state() if state := self.hass.states.get(self.entity_id): @@ -763,7 +763,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self.async_trigger, DOMAIN, str(self.name), - log_cb, + self._log_callback, home_assistant_start, variables, ) diff --git a/homeassistant/components/axis/entity.py b/homeassistant/components/axis/entity.py index 37be5355800..5a1fede53c7 100644 --- a/homeassistant/components/axis/entity.py +++ b/homeassistant/components/axis/entity.py @@ -42,7 +42,8 @@ class AxisEntity(Entity): self.device = device self._attr_device_info = DeviceInfo( - identifiers={(AXIS_DOMAIN, device.unique_id)} + identifiers={(AXIS_DOMAIN, device.unique_id)}, + serial_number=device.unique_id, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 573b154e2a4..29e40c8b336 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -247,8 +247,8 @@ "presence": { "name": "Presence", "state": { - "off": "[%key:component::device_tracker::entity_component::_::state::not_home%]", - "on": "[%key:component::device_tracker::entity_component::_::state::home%]" + "off": "[%key:common::state::not_home%]", + "on": "[%key:common::state::home%]" } }, "problem": { diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index 371bb1aec40..d6c3cda7ef4 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -8,14 +8,20 @@ from blebox_uniapi.feature import Feature from blebox_uniapi.session import ApiHost from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from .const import DEFAULT_SETUP_TIMEOUT, DOMAIN, PRODUCT +from .helpers import get_maybe_authenticated_session _LOGGER = logging.getLogger(__name__) @@ -36,12 +42,16 @@ _FeatureT = TypeVar("_FeatureT", bound=Feature) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BleBox devices from a config entry.""" - websession = async_get_clientsession(hass) - host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] + + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) + timeout = DEFAULT_SETUP_TIMEOUT + websession = get_maybe_authenticated_session(hass, password, username) + api_host = ApiHost(host, port, timeout, websession, hass.loop) try: diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index b43b1fb6b7f..31d1f6162d7 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -5,16 +5,22 @@ import logging from typing import Any from blebox_uniapi.box import Box -from blebox_uniapi.error import Error, UnsupportedBoxResponse, UnsupportedBoxVersion +from blebox_uniapi.error import ( + Error, + UnauthorizedRequest, + UnsupportedBoxResponse, + UnsupportedBoxVersion, +) from blebox_uniapi.session import ApiHost import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import get_maybe_authenticated_session from .const import ( ADDRESS_ALREADY_CONFIGURED, CANNOT_CONNECT, @@ -46,6 +52,8 @@ def create_schema(previous_input=None): { vol.Required(CONF_HOST, default=host): str, vol.Required(CONF_PORT, default=port): int, + vol.Inclusive(CONF_USERNAME, "auth"): str, + vol.Inclusive(CONF_PASSWORD, "auth"): str, } ) @@ -153,6 +161,9 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): addr = host_port(user_input) + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + for entry in self._async_current_entries(): if addr == host_port(entry.data): host, port = addr @@ -160,7 +171,9 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): reason=ADDRESS_ALREADY_CONFIGURED, description_placeholders={"address": f"{host}:{port}"}, ) - websession = async_get_clientsession(hass) + + websession = get_maybe_authenticated_session(hass, password, username) + api_host = ApiHost(*addr, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER) try: product = await Box.async_from_host(api_host) @@ -169,6 +182,10 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.handle_step_exception( "user", ex, schema, *addr, UNSUPPORTED_VERSION, _LOGGER.debug ) + except UnauthorizedRequest as ex: + return self.handle_step_exception( + "user", ex, schema, *addr, CANNOT_CONNECT, _LOGGER.error + ) except Error as ex: return self.handle_step_exception( diff --git a/homeassistant/components/blebox/helpers.py b/homeassistant/components/blebox/helpers.py new file mode 100644 index 00000000000..82b8080b61d --- /dev/null +++ b/homeassistant/components/blebox/helpers.py @@ -0,0 +1,21 @@ +"""Blebox helpers.""" +from __future__ import annotations + +import aiohttp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import ( + async_create_clientsession, + async_get_clientsession, +) + + +def get_maybe_authenticated_session( + hass: HomeAssistant, password: str | None, username: str | None +) -> aiohttp.ClientSession: + """Return proper session object.""" + if username and password: + auth = aiohttp.BasicAuth(login=username, password=password) + return async_create_clientsession(hass, auth=auth) + + return async_get_clientsession(hass) diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index b639e28d698..3eaa6d04ed2 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/blebox", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], - "requirements": ["blebox-uniapi==2.1.4"], + "requirements": ["blebox-uniapi==2.2.0"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index b94a77fbf18..c6413dd4372 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -1,7 +1,9 @@ """Support for Blink Home Camera System.""" +import asyncio from copy import deepcopy import logging +from aiohttp import ClientError from blinkpy.auth import Auth from blinkpy.blinkpy import Blink import voluptuous as vol @@ -16,8 +18,9 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( DEFAULT_SCAN_INTERVAL, @@ -28,6 +31,7 @@ from .const import ( SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -40,23 +44,7 @@ SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( ) -def _blink_startup_wrapper(hass: HomeAssistant, entry: ConfigEntry) -> Blink: - """Startup wrapper for blink.""" - blink = Blink() - auth_data = deepcopy(dict(entry.data)) - blink.auth = Auth(auth_data, no_prompt=True) - blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - - if blink.start(): - blink.setup_post_verify() - elif blink.auth.check_key_required(): - _LOGGER.debug("Attempting a reauth flow") - _reauth_flow_wrapper(hass, auth_data) - - return blink - - -def _reauth_flow_wrapper(hass, data): +async def _reauth_flow_wrapper(hass, data): """Reauth flow wrapper.""" hass.add_job( hass.config_entries.flow.async_init( @@ -79,10 +67,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = {**entry.data} if entry.version == 1: data.pop("login_response", None) - await hass.async_add_executor_job(_reauth_flow_wrapper, hass, data) + await _reauth_flow_wrapper(hass, data) return False if entry.version == 2: - await hass.async_add_executor_job(_reauth_flow_wrapper, hass, data) + await _reauth_flow_wrapper(hass, data) return False return True @@ -92,19 +80,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) _async_import_options_from_data_if_missing(hass, entry) - hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( - _blink_startup_wrapper, hass, entry - ) + session = async_get_clientsession(hass) + blink = Blink(session=session) + auth_data = deepcopy(dict(entry.data)) + blink.auth = Auth(auth_data, no_prompt=True, session=session) + blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + coordinator = BlinkUpdateCoordinator(hass, blink) - if not hass.data[DOMAIN][entry.entry_id].available: + try: + await blink.start() + except (ClientError, asyncio.TimeoutError) as ex: + raise ConfigEntryNotReady("Can not connect to host") from ex + + if blink.auth.check_key_required(): + _LOGGER.debug("Attempting a reauth flow") + raise ConfigEntryAuthFailed("Need 2FA for Blink") + + if not blink.available: raise ConfigEntryNotReady + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) - def blink_refresh(event_time=None): + async def blink_refresh(event_time=None): """Call blink to refresh info.""" - hass.data[DOMAIN][entry.entry_id].refresh(force_cache=True) + await coordinator.api.refresh(force_cache=True) async def async_save_video(call): """Call save video service handler.""" @@ -114,11 +117,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Call save recent clips service handler.""" await async_handle_save_recent_clips_service(hass, entry, call) - def send_pin(call): + async def send_pin(call): """Call blink to send new pin.""" pin = call.data[CONF_PIN] - hass.data[DOMAIN][entry.entry_id].auth.send_auth_key( - hass.data[DOMAIN][entry.entry_id], + await coordinator.api.auth.send_auth_key( + hass.data[DOMAIN][entry.entry_id].api, pin, ) @@ -153,64 +156,53 @@ def _async_import_options_from_data_if_missing( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Blink entry.""" - unload_ok = 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) + if not hass.data[DOMAIN]: + return True - if not unload_ok: - return False + hass.services.async_remove(DOMAIN, SERVICE_REFRESH) + hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO) + hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN) - hass.data[DOMAIN].pop(entry.entry_id) - - if len(hass.data[DOMAIN]) != 0: - return True - - hass.services.async_remove(DOMAIN, SERVICE_REFRESH) - hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO) - hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN) - - return True + return unload_ok async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" - blink: Blink = hass.data[DOMAIN][entry.entry_id] + blink: Blink = hass.data[DOMAIN][entry.entry_id].api blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL] -async def async_handle_save_video_service(hass, entry, call): +async def async_handle_save_video_service( + hass: HomeAssistant, entry: ConfigEntry, call +) -> None: """Handle save video service calls.""" camera_name = call.data[CONF_NAME] video_path = call.data[CONF_FILENAME] if not hass.config.is_allowed_path(video_path): _LOGGER.error("Can't write %s, no access to path!", video_path) return - - def _write_video(name, file_path): - """Call video write.""" - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if name in all_cameras: - all_cameras[name].video_to_file(file_path) - - try: - await hass.async_add_executor_job(_write_video, camera_name, video_path) - except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) + all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras + if camera_name in all_cameras: + try: + await all_cameras[camera_name].video_to_file(video_path) + except OSError as err: + _LOGGER.error("Can't write image to file: %s", err) -async def async_handle_save_recent_clips_service(hass, entry, call): +async def async_handle_save_recent_clips_service( + hass: HomeAssistant, entry: ConfigEntry, call +) -> None: """Save multiple recent clips to output directory.""" camera_name = call.data[CONF_NAME] clips_dir = call.data[CONF_FILE_PATH] if not hass.config.is_allowed_path(clips_dir): _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) return - - def _save_recent_clips(name, output_dir): - """Call save recent clips.""" - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if name in all_cameras: - all_cameras[name].save_recent_clips(output_dir=output_dir) - - try: - await hass.async_add_executor_job(_save_recent_clips, camera_name, clips_dir) - except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) + all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras + if camera_name in all_cameras: + try: + await all_cameras[camera_name].save_recent_clips(output_dir=clips_dir) + except OSError as err: + _LOGGER.error("Can't write recent clips to directory: %s", err) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 16a8c00d67a..d1fcb889fb8 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -1,8 +1,11 @@ """Support for Blink Alarm Control Panel.""" from __future__ import annotations +import asyncio import logging +from blinkpy.blinkpy import Blink, BlinkSyncModule + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, @@ -13,11 +16,14 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,59 +34,75 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Blink Alarm Control Panels.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] sync_modules = [] - for sync_name, sync_module in data.sync.items(): - sync_modules.append(BlinkSyncModule(data, sync_name, sync_module)) + for sync_name, sync_module in coordinator.api.sync.items(): + sync_modules.append(BlinkSyncModuleHA(coordinator, sync_name, sync_module)) async_add_entities(sync_modules) -class BlinkSyncModule(AlarmControlPanelEntity): +class BlinkSyncModuleHA( + CoordinatorEntity[BlinkUpdateCoordinator], AlarmControlPanelEntity +): """Representation of a Blink Alarm Control Panel.""" _attr_icon = ICON _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY - _attr_name = None _attr_has_entity_name = True + _attr_name = None - def __init__(self, data, name, sync): + def __init__( + self, coordinator: BlinkUpdateCoordinator, name: str, sync: BlinkSyncModule + ) -> None: """Initialize the alarm control panel.""" - self.data = data + super().__init__(coordinator) + self.api: Blink = coordinator.api + self._coordinator = coordinator self.sync = sync - self._name = name - self._attr_unique_id = sync.serial + self._attr_unique_id: str = sync.serial self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sync.serial)}, name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, + serial_number=sync.serial, ) + self._update_attr() - def update(self) -> None: - """Update the state of the device.""" - if self.data.check_if_ok_to_update(): - _LOGGER.debug( - "Initiating a blink.refresh() from BlinkSyncModule('%s') (%s)", - self._name, - self.data, - ) - self.data.refresh() - _LOGGER.info("Updating State of Blink Alarm Control Panel '%s'", self._name) + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update.""" + self._update_attr() + super()._handle_coordinator_update() - self._attr_state = ( - STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED - ) - self.sync.attributes["network_info"] = self.data.networks + @callback + def _update_attr(self) -> None: + """Update attributes for alarm control panel.""" + self.sync.attributes["network_info"] = self.api.networks self.sync.attributes["associated_cameras"] = list(self.sync.cameras) self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION self._attr_extra_state_attributes = self.sync.attributes + self._attr_state = ( + STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED + ) - def alarm_disarm(self, code: str | None = None) -> None: + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - self.sync.arm = False - self.sync.refresh() + try: + await self.sync.async_arm(False) - def alarm_arm_away(self, code: str | None = None) -> None: + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to disarm camera") from er + + await self._coordinator.async_refresh() + + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" - self.sync.arm = True - self.sync.refresh() + try: + await self.sync.async_arm(True) + + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to arm camera away") from er + + await self._coordinator.async_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 1b53a11b1d2..47b45e2f4ec 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -10,9 +10,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DEFAULT_BRAND, @@ -21,6 +22,7 @@ from .const import ( TYPE_CAMERA_ARMED, TYPE_MOTION_DETECTED, ) +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -45,45 +47,58 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the blink binary sensors.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ - BlinkBinarySensor(data, camera, description) - for camera in data.cameras + BlinkBinarySensor(coordinator, camera, description) + for camera in coordinator.api.cameras for description in BINARY_SENSORS_TYPES ] async_add_entities(entities) -class BlinkBinarySensor(BinarySensorEntity): +class BlinkBinarySensor(CoordinatorEntity[BlinkUpdateCoordinator], BinarySensorEntity): """Representation of a Blink binary sensor.""" _attr_has_entity_name = True def __init__( - self, data, camera, description: BinarySensorEntityDescription + self, + coordinator: BlinkUpdateCoordinator, + camera, + description: BinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" - self.data = data + super().__init__(coordinator) self.entity_description = description - self._camera = data.cameras[camera] - self._attr_unique_id = f"{self._camera.serial}-{description.key}" + self._camera = coordinator.api.cameras[camera] + serial = self._camera.serial + self._attr_unique_id = f"{serial}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._camera.serial)}, + identifiers={(DOMAIN, serial)}, + serial_number=serial, name=camera, manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, ) + self._update_attrs() - def update(self) -> None: - """Update sensor state.""" - state = self._camera.attributes[self.entity_description.key] + @callback + def _handle_coordinator_update(self) -> None: + """Handle update from data coordinator.""" + self._update_attrs() + super()._handle_coordinator_update() + + @callback + def _update_attrs(self) -> None: + """Update attributes for binary sensor.""" + is_on = self._camera.attributes[self.entity_description.key] _LOGGER.debug( "'%s' %s = %s", self._camera.attributes["name"], self.entity_description.key, - state, + is_on, ) if self.entity_description.key == TYPE_BATTERY: - state = state != "ok" - self._attr_is_on = state + is_on = is_on != "ok" + self._attr_is_on = is_on diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 9f9396c3888..c967ff59c8c 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,32 +1,41 @@ """Support for Blink system camera.""" from __future__ import annotations +import asyncio +from collections.abc import Mapping +import contextlib import logging +from typing import Any from requests.exceptions import ChunkedEncodingError from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_BRAND, DOMAIN, SERVICE_TRIGGER +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) ATTR_VIDEO_CLIP = "video" ATTR_IMAGE = "image" +PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a Blink Camera.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ - BlinkCamera(data, name, camera) for name, camera in data.cameras.items() + BlinkCamera(coordinator, name, camera) + for name, camera in coordinator.api.cameras.items() ] async_add_entities(entities) @@ -35,20 +44,22 @@ async def async_setup_entry( platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") -class BlinkCamera(Camera): +class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """An implementation of a Blink Camera.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, data, name, camera): + def __init__(self, coordinator: BlinkUpdateCoordinator, name, camera) -> None: """Initialize a camera.""" - super().__init__() - self.data = data + super().__init__(coordinator) + Camera.__init__(self) + self._coordinator = coordinator self._camera = camera self._attr_unique_id = f"{camera.serial}-camera" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, camera.serial)}, + serial_number=camera.serial, name=name, manufacturer=DEFAULT_BRAND, model=camera.camera_type, @@ -56,19 +67,30 @@ class BlinkCamera(Camera): _LOGGER.debug("Initialized blink camera %s", self.name) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the camera attributes.""" return self._camera.attributes - def enable_motion_detection(self) -> None: + async def async_enable_motion_detection(self) -> None: """Enable motion detection for the camera.""" - self._camera.arm = True - self.data.refresh() + try: + await self._camera.async_arm(True) - def disable_motion_detection(self) -> None: + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to arm camera") from er + + self._camera.motion_enabled = True + await self._coordinator.async_refresh() + + async def async_disable_motion_detection(self) -> None: """Disable motion detection for the camera.""" - self._camera.arm = False - self.data.refresh() + try: + await self._camera.async_arm(False) + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to disarm camera") from er + + self._camera.motion_enabled = False + await self._coordinator.async_refresh() @property def motion_detection_enabled(self) -> bool: @@ -76,21 +98,23 @@ class BlinkCamera(Camera): return self._camera.arm @property - def brand(self): + def brand(self) -> str | None: """Return the camera brand.""" return DEFAULT_BRAND - def trigger_camera(self): + async def trigger_camera(self) -> None: """Trigger camera to take a snapshot.""" - self._camera.snap_picture() - self.data.refresh() + with contextlib.suppress(asyncio.TimeoutError): + await self._camera.snap_picture() + await self._coordinator.api.refresh() + self.async_write_ha_state() def camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" try: - return self._camera.image_from_cache.content + return self._camera.image_from_cache except ChunkedEncodingError: _LOGGER.debug("Could not retrieve image for %s", self._camera.name) return None diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index d3b2878b522..4326c6cb86c 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -16,10 +16,11 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, @@ -49,23 +50,23 @@ OPTIONS_FLOW = { } -def validate_input(auth: Auth) -> None: +async def validate_input(auth: Auth) -> None: """Validate the user input allows us to connect.""" try: - auth.startup() + await auth.startup() except (LoginError, TokenRefreshFailed) as err: raise InvalidAuth from err if auth.check_key_required(): raise Require2FA -def _send_blink_2fa_pin(auth: Auth, pin: str | None) -> bool: +async def _send_blink_2fa_pin(hass: HomeAssistant, auth: Auth, pin: str | None) -> bool: """Send 2FA pin to blink servers.""" - blink = Blink() + blink = Blink(session=async_get_clientsession(hass)) blink.auth = auth blink.setup_login_ids() blink.setup_urls() - return auth.send_auth_key(blink, pin) + return await auth.send_auth_key(blink, pin) class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): @@ -91,11 +92,15 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initiated by the user.""" errors = {} if user_input is not None: - self.auth = Auth({**user_input, "device_id": DEVICE_ID}, no_prompt=True) + self.auth = Auth( + {**user_input, "device_id": DEVICE_ID}, + no_prompt=True, + session=async_get_clientsession(self.hass), + ) await self.async_set_unique_id(user_input[CONF_USERNAME]) try: - await self.hass.async_add_executor_job(validate_input, self.auth) + await validate_input(self.auth) return self._async_finish_flow() except Require2FA: return await self.async_step_2fa() @@ -122,11 +127,9 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle 2FA step.""" errors = {} if user_input is not None: - pin: str | None = user_input.get(CONF_PIN) try: - assert self.auth - valid_token = await self.hass.async_add_executor_job( - _send_blink_2fa_pin, self.auth, pin + valid_token = await _send_blink_2fa_pin( + self.hass, self.auth, user_input.get(CONF_PIN) ) except BlinkSetupError: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index d58920562f4..7de42a80efc 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -7,7 +7,6 @@ DEVICE_ID = "Home Assistant" CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" - DEFAULT_BRAND = "Blink" DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com" DEFAULT_SCAN_INTERVAL = 300 diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py new file mode 100644 index 00000000000..d3f7551e1b2 --- /dev/null +++ b/homeassistant/components/blink/coordinator.py @@ -0,0 +1,33 @@ +"""Blink Coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from blinkpy.blinkpy import Blink + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """BlinkUpdateCoordinator - In charge of downloading the data for a site.""" + + def __init__(self, hass: HomeAssistant, api: Blink) -> None: + """Initialize the data service.""" + self.api = api + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Async update wrapper.""" + return await self.api.refresh(force=True) diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 302a9f1e86a..54f36ec6e2e 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.21.0"] + "requirements": ["blinkpy==0.22.2"] } diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index ceec74a9aa9..064ad9d04f2 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -14,11 +15,13 @@ from homeassistant.const import ( EntityCategory, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,6 +31,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_WIFI_STRENGTH, @@ -35,6 +39,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -43,41 +48,57 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize a Blink sensor.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ - BlinkSensor(data, camera, description) - for camera in data.cameras + BlinkSensor(coordinator, camera, description) + for camera in coordinator.api.cameras for description in SENSOR_TYPES ] async_add_entities(entities) -class BlinkSensor(SensorEntity): +class BlinkSensor(CoordinatorEntity[BlinkUpdateCoordinator], SensorEntity): """A Blink camera sensor.""" _attr_has_entity_name = True - def __init__(self, data, camera, description: SensorEntityDescription) -> None: + def __init__( + self, + coordinator: BlinkUpdateCoordinator, + camera, + description: SensorEntityDescription, + ) -> None: """Initialize sensors from Blink camera.""" + super().__init__(coordinator) self.entity_description = description - self.data = data - self._camera = data.cameras[camera] - self._attr_unique_id = f"{self._camera.serial}-{description.key}" + + self._camera = coordinator.api.cameras[camera] + serial = self._camera.serial + self._attr_unique_id = f"{serial}-{description.key}" self._sensor_key = ( "temperature_calibrated" if description.key == "temperature" else description.key ) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._camera.serial)}, + identifiers={(DOMAIN, serial)}, + serial_number=serial, name=f"{DOMAIN} {camera}", manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, ) + self._update_attr() - def update(self) -> None: - """Retrieve sensor data from the camera.""" + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update.""" + self._update_attr() + super()._handle_coordinator_update() + + @callback + def _update_attr(self) -> None: + """Update attributes for sensor.""" try: self._attr_native_value = self._camera.attributes[self._sensor_key] _LOGGER.debug( diff --git a/homeassistant/components/bluemaestro/strings.json b/homeassistant/components/bluemaestro/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/bluemaestro/strings.json +++ b/homeassistant/components/bluemaestro/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py index b6a70e32865..f17bcf938f5 100644 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ b/homeassistant/components/bluetooth/advertisement_tracker.py @@ -18,11 +18,12 @@ TRACKER_BUFFERING_WOBBLE_SECONDS = 5 class AdvertisementTracker: """Tracker to determine the interval that a device is advertising.""" - __slots__ = ("intervals", "sources", "_timings") + __slots__ = ("intervals", "fallback_intervals", "sources", "_timings") def __init__(self) -> None: """Initialize the tracker.""" self.intervals: dict[str, float] = {} + self.fallback_intervals: dict[str, float] = {} self.sources: dict[str, str] = {} self._timings: dict[str, list[float]] = {} @@ -31,6 +32,7 @@ class AdvertisementTracker: """Return diagnostics.""" return { "intervals": self.intervals, + "fallback_intervals": self.fallback_intervals, "sources": self.sources, "timings": self._timings, } @@ -67,6 +69,11 @@ class AdvertisementTracker: self.sources.pop(address, None) self._timings.pop(address, None) + @callback + def async_remove_fallback_interval(self, address: str) -> None: + """Remove fallback interval.""" + self.fallback_intervals.pop(address, None) + @callback def async_remove_source(self, source: str) -> None: """Remove the tracker.""" diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 240610e4868..8eacd3e291a 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -330,7 +330,7 @@ class BaseHaRemoteScanner(BaseHaScanner): prev_manufacturer_data = prev_advertisement.manufacturer_data prev_name = prev_device.name - if local_name and prev_name and len(prev_name) > len(local_name): + if prev_name and (not local_name or len(prev_name) > len(local_name)): local_name = prev_name if service_uuids and service_uuids != prev_service_uuids: diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index d69558fe7fd..34edccaf4ab 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -109,6 +109,7 @@ class BluetoothManager: "_cancel_logging_listener", "_advertisement_tracker", "_fallback_intervals", + "_intervals", "_unavailable_callbacks", "_connectable_unavailable_callbacks", "_callback_index", @@ -140,7 +141,8 @@ class BluetoothManager: self._cancel_logging_listener: CALLBACK_TYPE | None = None self._advertisement_tracker = AdvertisementTracker() - self._fallback_intervals: dict[str, float] = {} + self._fallback_intervals = self._advertisement_tracker.fallback_intervals + self._intervals = self._advertisement_tracker.intervals self._unavailable_callbacks: dict[ str, list[Callable[[BluetoothServiceInfoBleak], None]] @@ -359,7 +361,7 @@ class BluetoothManager: # The second loop (connectable=False) is responsible for removing # the device from all the interval tracking since it is no longer # available for both connectable and non-connectable - self._fallback_intervals.pop(address, None) + tracker.async_remove_fallback_interval(address) tracker.async_remove_address(address) self._integration_matcher.async_clear_address(address) self._async_dismiss_discoveries(address) @@ -390,7 +392,7 @@ class BluetoothManager: ) -> bool: """Prefer previous advertisement from a different source if it is better.""" if new.time - old.time > ( - stale_seconds := self._advertisement_tracker.intervals.get( + stale_seconds := self._intervals.get( new.address, self._fallback_intervals.get( new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS @@ -791,7 +793,7 @@ class BluetoothManager: @hass_callback def async_get_learned_advertising_interval(self, address: str) -> float | None: """Get the learned advertising interval for a MAC address.""" - return self._advertisement_tracker.intervals.get(address) + return self._intervals.get(address) @hass_callback def async_get_fallback_availability_interval(self, address: str) -> float | None: diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 960a86637ae..06e7d34e68d 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.2.1", + "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.13.0", diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 7294d55f912..7dd39c14039 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast from homeassistant import config_entries from homeassistant.const import ( + ATTR_CONNECTIONS, ATTR_IDENTIFIERS, ATTR_NAME, CONF_ENTITY_CATEGORY, @@ -16,11 +17,12 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import async_get_current_platform from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.enum import try_parse_enum from .const import DOMAIN @@ -119,7 +121,7 @@ def serialize_entity_description(description: EntityDescription) -> dict[str, An } -@dataclasses.dataclass(slots=True, frozen=True) +@dataclasses.dataclass(slots=True, frozen=False) class PassiveBluetoothDataUpdate(Generic[_T]): """Generic bluetooth data.""" @@ -134,12 +136,33 @@ class PassiveBluetoothDataUpdate(Generic[_T]): default_factory=dict ) - def update(self, new_data: PassiveBluetoothDataUpdate[_T]) -> None: - """Update the data.""" - self.devices.update(new_data.devices) - self.entity_descriptions.update(new_data.entity_descriptions) - self.entity_data.update(new_data.entity_data) - self.entity_names.update(new_data.entity_names) + def update( + self, new_data: PassiveBluetoothDataUpdate[_T] + ) -> set[PassiveBluetoothEntityKey] | None: + """Update the data and returned changed PassiveBluetoothEntityKey or None on device change. + + The changed PassiveBluetoothEntityKey can be used to filter + which listeners are called. + """ + device_change = False + changed_entity_keys: set[PassiveBluetoothEntityKey] = set() + for key, device_info in new_data.devices.items(): + if device_change or self.devices.get(key, UNDEFINED) != device_info: + device_change = True + self.devices[key] = device_info + for incoming, current in ( + (new_data.entity_descriptions, self.entity_descriptions), + (new_data.entity_names, self.entity_names), + (new_data.entity_data, self.entity_data), + ): + # mypy can't seem to work this out + for key, data in incoming.items(): # type: ignore[attr-defined] + if current.get(key, UNDEFINED) != data: # type: ignore[attr-defined] + changed_entity_keys.add(key) # type: ignore[arg-type] + current[key] = data # type: ignore[index] + # If the device changed we don't need to return the changed + # entity keys as all entities will be updated + return None if device_change else changed_entity_keys def async_get_restore_data(self) -> RestoredPassiveBluetoothDataUpdate: """Serialize restore data to storage.""" @@ -470,7 +493,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): data: PassiveBluetoothDataUpdate[_T] | None, ) -> None: """Listen for new entities.""" - if data is None: + if data is None or created.issuperset(data.entity_descriptions): return entities: list[PassiveBluetoothProcessorEntity] = [] for entity_key, description in data.entity_descriptions.items(): @@ -520,6 +543,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): self, data: PassiveBluetoothDataUpdate[_T] | None, was_available: bool | None = None, + changed_entity_keys: set[PassiveBluetoothEntityKey] | None = None, ) -> None: """Update all registered listeners.""" if was_available is None: @@ -542,6 +566,12 @@ class PassiveBluetoothDataProcessor(Generic[_T]): # if the key is in the data entity_key_listeners = self._entity_key_listeners for entity_key in data.entity_data: + if ( + was_available + and changed_entity_keys is not None + and entity_key not in changed_entity_keys + ): + continue if maybe_listener := entity_key_listeners.get(entity_key): for update_callback in maybe_listener: update_callback(data) @@ -573,8 +603,8 @@ class PassiveBluetoothDataProcessor(Generic[_T]): "Processing %s data recovered", self.coordinator.name ) - self.data.update(new_data) - self.async_update_listeners(new_data, was_available) + changed_entity_keys = self.data.update(new_data) + self.async_update_listeners(new_data, was_available, changed_entity_keys) class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]): @@ -615,6 +645,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce self._attr_unique_id = f"{address}-{key}" if ATTR_NAME not in self._attr_device_info: self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name + if device_id is None: + self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)} self._attr_name = processor.entity_names.get(entity_key) @property diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index d3711a8f2e6..0e3750de085 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -30,6 +30,8 @@ _LOGGER = logging.getLogger(__name__) ALLOWED_CONDITION_BASED_SERVICE_KEYS = { "BRAKE_FLUID", + "BRAKE_PADS_FRONT", + "BRAKE_PADS_REAR", "EMISSION_CHECK", "ENGINE_OIL", "OIL", diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index d64541d73be..b5652694120 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.14.1"] + "requirements": ["bimmer-connected==0.14.2"] } diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 3a161a74bc5..60b9a7b492f 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -7,7 +7,7 @@ from typing import Any, cast from aiohttp import ClientResponseError from bond_async import Action, Bond, BondType -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency from .const import BRIDGE_MAKE @@ -70,7 +70,7 @@ class BondDevice: @property def trust_state(self) -> bool: """Check if Trust State is turned on.""" - return self.props.get("trust_state", False) + return self.props.get("trust_state", False) # type: ignore[no-any-return] def has_action(self, action: str) -> bool: """Check to see if the device supports an actions.""" @@ -163,7 +163,7 @@ class BondHub: ] ) - responses = await gather_with_concurrency(MAX_REQUESTS, *tasks) + responses = await gather_with_limited_concurrency(MAX_REQUESTS, *tasks) response_idx = 0 for device_id in setup_device_ids: self._devices.append( @@ -203,7 +203,7 @@ class BondHub: @property def make(self) -> str: """Return this hub make.""" - return self._version.get("make", BRIDGE_MAKE) + return self._version.get("make", BRIDGE_MAKE) # type: ignore[no-any-return] @property def name(self) -> str: diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 27ac97a27dc..0f8f94c73c4 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -4,18 +4,23 @@ from __future__ import annotations from asyncio import timeout from datetime import timedelta import logging - -from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError +import sys +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_CONFIG_ENTRY, DOMAIN, SNMP from .utils import get_snmp_engine +if sys.version_info < (3, 12): + from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError +else: + BrotherSensors = Any + PLATFORMS = [Platform.SENSOR] SCAN_INTERVAL = timedelta(seconds=30) @@ -25,6 +30,10 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Brother from a config entry.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Brother Printer is not supported on Python 3.12. Please use Python 3.11." + ) host = entry.data[CONF_HOST] printer_type = entry.data[CONF_TYPE] diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 4ea6f7abbad..e9554d84207 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -383,6 +383,7 @@ async def async_setup_entry( device_info = DeviceInfo( configuration_url=f"http://{entry.data[CONF_HOST]}/", identifiers={(DOMAIN, coordinator.data.serial)}, + serial_number=coordinator.data.serial, manufacturer="Brother", model=coordinator.data.model, name=coordinator.data.model, diff --git a/homeassistant/components/brother/utils.py b/homeassistant/components/brother/utils.py index e421be52154..cd472b9b754 100644 --- a/homeassistant/components/brother/utils.py +++ b/homeassistant/components/brother/utils.py @@ -1,8 +1,8 @@ """Brother helpers functions.""" -import logging +from __future__ import annotations -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio.cmdgen import lcd +import logging +import sys from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -10,6 +10,10 @@ from homeassistant.helpers import singleton from .const import DOMAIN, SNMP +if sys.version_info < (3, 12): + import pysnmp.hlapi.asyncio as hlapi + from pysnmp.hlapi.asyncio.cmdgen import lcd + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py index 1de24ffa76c..ac9a764179e 100644 --- a/homeassistant/components/brottsplatskartan/config_flow.py +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -18,11 +18,10 @@ DATA_SCHEMA = vol.Schema( vol.Optional(CONF_LOCATION): selector.LocationSelector( selector.LocationSelectorConfig(radius=False, icon="") ), - vol.Optional(CONF_AREA, default="none"): selector.SelectSelector( + vol.Optional(CONF_AREA): selector.SelectSelector( selector.SelectSelectorConfig( options=AREAS, mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="areas", ) ), } @@ -34,21 +33,6 @@ class BPKConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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: @@ -58,9 +42,7 @@ class BPKConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 - ) + area: str | None = user_input.get(CONF_AREA) if area: name = f"{DEFAULT_NAME} {area}" diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py index 8bd08f452f4..b53a39755a6 100644 --- a/homeassistant/components/brottsplatskartan/const.py +++ b/homeassistant/components/brottsplatskartan/const.py @@ -14,7 +14,6 @@ CONF_APP_ID = "app_id" DEFAULT_NAME = "Brottsplatskartan" AREAS = [ - "none", "Blekinge län", "Dalarnas län", "Gotlands län", diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index 7d24ebd50b7..df17832f695 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -5,66 +5,18 @@ from collections import defaultdict from datetime import timedelta from brottsplatskartan import ATTRIBUTION, BrottsplatsKartan -import voluptuous as vol -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 DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN, LOGGER +from .const import CONF_APP_ID, CONF_AREA, DOMAIN, LOGGER SCAN_INTERVAL = timedelta(minutes=30) -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_AREA, default=[]): vol.All(cv.ensure_list, [vol.In(AREAS)]), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Brottsplatskartan platform.""" - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.11.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Brottsplatskartan", - }, - ) - - 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 diff --git a/homeassistant/components/brottsplatskartan/strings.json b/homeassistant/components/brottsplatskartan/strings.json index f10120f7884..bd8d5ad8dbe 100644 --- a/homeassistant/components/brottsplatskartan/strings.json +++ b/homeassistant/components/brottsplatskartan/strings.json @@ -15,12 +15,5 @@ } } } - }, - "selector": { - "areas": { - "options": { - "none": "No area" - } - } } } diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 751c8f74bf9..566609b998b 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -14,7 +14,11 @@ from homeassistant.components.bluetooth 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 homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceRegistry, + async_get, +) from .const import ( BTHOME_BLE_EVENT, @@ -55,6 +59,7 @@ def process_service_info( sensor_device_info = update.devices[device_key.device_id] device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, + connections={(CONNECTION_BLUETOOTH, address)}, identifiers={(BLUETOOTH_DOMAIN, address)}, manufacturer=sensor_device_info.manufacturer, model=sensor_device_info.model, diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 01db154306f..a7729cc256e 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.1.1"] + "requirements": ["bthome-ble==3.2.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 06f205246c8..10ba292d20c 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -228,6 +228,10 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, ), + # Raw (-) + (BTHomeExtendedSensorDeviceClass.RAW, None): SensorEntityDescription( + key=str(BTHomeExtendedSensorDeviceClass.RAW), + ), # Rotation (°) (BTHomeSensorDeviceClass.ROTATION, Units.DEGREE): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.ROTATION}_{Units.DEGREE}", diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index 020a0206e73..39ba3baa3fd 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index 87810edda2e..4a81a774b4f 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -32,8 +32,8 @@ from .const import ( OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY): vol.In( - SUPPORTED_COUNTRY_CODES + vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY): selector.CountrySelector( + selector.CountrySelectorConfig(countries=SUPPORTED_COUNTRY_CODES) ), vol.Optional(CONF_DELTA, default=DEFAULT_DELTA): selector.NumberSelector( selector.NumberSelectorConfig( diff --git a/homeassistant/components/buienradar/strings.json b/homeassistant/components/buienradar/strings.json index 2141f420167..279d81f22ab 100644 --- a/homeassistant/components/buienradar/strings.json +++ b/homeassistant/components/buienradar/strings.json @@ -428,9 +428,9 @@ "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", - "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", - "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", - "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "conditiondetailed_5d": { diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index f30f79f7275..4fe5e38432a 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -114,8 +114,19 @@ def setup_platform( ) ) - # Create a default calendar if there was no custom one + # Create a default calendar if there was no custom one for all calendars + # that support events. if not config[CONF_CUSTOM_CALENDARS]: + if ( + supported_components := calendar.get_supported_components() + ) and "VEVENT" not in supported_components: + _LOGGER.debug( + "Ignoring calendar '%s' (components=%s)", + calendar.name, + supported_components, + ) + continue + name = calendar.name device_id = calendar.name entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 7261e422bb7..65a61e71d3a 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -815,7 +815,7 @@ def _validate_timespan( This converts the input service arguments into a `start` and `end` date or date time. This exists because service calls use `start_date` and `start_date_time` whereas the - normal entity methods can take either a `datetim` or `date` as a single `start` argument. + normal entity methods can take either a `datetime` or `date` as a single `start` argument. It also handles the other service call variations like "in days" as well. """ @@ -851,7 +851,7 @@ async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None: async def async_list_events_service( calendar: CalendarEntity, service_call: ServiceCall ) -> ServiceResponse: - """List events on a calendar during a time drange.""" + """List events on a calendar during a time range.""" start = service_call.data.get(EVENT_START_DATETIME, dt_util.now()) if EVENT_DURATION in service_call.data: end = start + service_call.data[EVENT_DURATION] diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index f8a6014e261..073c41fc0df 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -75,15 +75,15 @@ class Timespan: This effectively gives us a cursor like interface for advancing through time using the interval as a hint. The returned span may have a - different interval than the one specified. For example, time span may + different interval than the one specified. For example, time span may be longer during a daylight saving time transition, or may extend due to - drift if the current interval is old. The returned time span is + drift if the current interval is old. The returned time span is adjacent and non-overlapping. """ return Timespan(self.end, max(self.end, now) + interval) def __str__(self) -> str: - """Return a string representing the half open interval timespan.""" + """Return a string representing the half open interval time span.""" return f"[{self.start}, {self.end})" @@ -118,7 +118,7 @@ def queued_event_fetcher( offset_timespan = timespan.with_offset(-1 * offset) active_events = await fetcher(offset_timespan) - # Determine the trigger eligibilty of events during this time span. + # Determine the trigger eligibility of events during this time span. # Example: For an EVENT_END trigger the event may start during this # time span, but need to be triggered later when the end happens. results = [] @@ -130,7 +130,7 @@ def queued_event_fetcher( results.append(QueuedCalendarEvent(trigger_time + offset, event)) _LOGGER.debug( - "Scan events @ %s%s found %s eligble of %s active", + "Scan events @ %s%s found %s eligible of %s active", offset_timespan, f" (offset={offset})" if offset else "", len(results), diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json index 4fe333f40a5..6f4e1ead956 100644 --- a/homeassistant/components/cisco_webex_teams/manifest.json +++ b/homeassistant/components/cisco_webex_teams/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/cisco_webex_teams", "iot_class": "cloud_push", "loggers": ["webexteamssdk"], - "requirements": ["webexteamssdk==1.1.1"] + "requirements": ["webexteamssdk==1.1.1;python_version<'3.12'"] } diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py index be8710c7096..d2c75d78390 100644 --- a/homeassistant/components/cisco_webex_teams/notify.py +++ b/homeassistant/components/cisco_webex_teams/notify.py @@ -2,9 +2,9 @@ from __future__ import annotations import logging +import sys import voluptuous as vol -from webexteamssdk import ApiError, WebexTeamsAPI, exceptions from homeassistant.components.notify import ( ATTR_TITLE, @@ -13,9 +13,14 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +if sys.version_info < (3, 12): + from webexteamssdk import ApiError, WebexTeamsAPI, exceptions + + _LOGGER = logging.getLogger(__name__) CONF_ROOM_ID = "room_id" @@ -31,6 +36,10 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> CiscoWebexTeamsNotificationService | None: """Get the CiscoWebexTeams notification service.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Cisco Webex Teams is not supported on Python 3.12. Please use Python 3.11." + ) client = WebexTeamsAPI(access_token=config[CONF_TOKEN]) try: diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index c216ec85c5c..41ea4aa2b7d 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -19,6 +19,7 @@ from homeassistant.components.alexa import ( from homeassistant.components.google_assistant import smart_home as ga from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import Context, HassJob, HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.util.aiohttp import MockRequest, serialize_response @@ -86,6 +87,11 @@ class CloudClient(Interface): """Return true if we want start a remote connection.""" return self._prefs.remote_enabled + @property + def client_name(self) -> str: + """Return the client name that will be used for API calls.""" + return SERVER_SOFTWARE + @property def relayer_region(self) -> str | None: """Return the connected relayer region.""" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index fe0628f1886..6d5c954361b 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.71.0"] + "requirements": ["hass-nabucasa==0.74.0"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 8b6f773e5d9..57179431574 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -197,7 +197,7 @@ class CloudPreferences: @property def alexa_report_state(self) -> bool: """Return if Alexa report state is enabled.""" - return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) + return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) # type: ignore[no-any-return] @property def alexa_default_expose(self) -> list[str] | None: @@ -210,7 +210,7 @@ class CloudPreferences: @property def alexa_entity_configs(self) -> dict[str, Any]: """Return Alexa Entity configurations.""" - return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) # type: ignore[no-any-return] @property def alexa_settings_version(self) -> int: @@ -227,7 +227,7 @@ class CloudPreferences: @property def google_report_state(self) -> bool: """Return if Google report state is enabled.""" - return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) + return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) # type: ignore[no-any-return] @property def google_secure_devices_pin(self) -> str | None: @@ -237,7 +237,7 @@ class CloudPreferences: @property def google_entity_configs(self) -> dict[str, dict[str, Any]]: """Return Google Entity configurations.""" - return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) # type: ignore[no-any-return] @property def google_settings_version(self) -> int: @@ -262,12 +262,12 @@ class CloudPreferences: @property def cloudhooks(self) -> dict[str, Any]: """Return the published cloud webhooks.""" - return self._prefs.get(PREF_CLOUDHOOKS, {}) + return self._prefs.get(PREF_CLOUDHOOKS, {}) # type: ignore[no-any-return] @property def tts_default_voice(self) -> tuple[str, str]: """Return the default TTS voice.""" - return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) + return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return] async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 92b09b6e17a..d41bd6e0f78 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -9,15 +9,20 @@ from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import CONF_COUNTRY_CODE, DOMAIN from .coordinator import get_data from .exceptions import APIRatelimitExceeded, InvalidAuth from .util import get_extra_name -TYPE_USE_HOME = "Use home location" -TYPE_SPECIFY_COORDINATES = "Specify coordinates" -TYPE_SPECIFY_COUNTRY = "Specify country code" +TYPE_USE_HOME = "use_home_location" +TYPE_SPECIFY_COORDINATES = "specify_coordinates" +TYPE_SPECIFY_COUNTRY = "specify_country_code" class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -32,11 +37,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" data_schema = vol.Schema( { - vol.Required("location", default=TYPE_USE_HOME): vol.In( - ( - TYPE_USE_HOME, - TYPE_SPECIFY_COORDINATES, - TYPE_SPECIFY_COUNTRY, + vol.Required("location"): SelectSelector( + SelectSelectorConfig( + translation_key="location", + mode=SelectSelectorMode.LIST, + options=[ + TYPE_USE_HOME, + TYPE_SPECIFY_COORDINATES, + TYPE_SPECIFY_COUNTRY, + ], ) ), vol.Required(CONF_API_KEY): cv.string, diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 7dbcd2e7966..4564fdf14be 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -34,10 +34,29 @@ "entity": { "sensor": { "carbon_intensity": { - "name": "CO2 intensity" + "name": "CO2 intensity", + "state_attributes": { + "country_code": { + "name": "Country code" + } + } }, "fossil_fuel_percentage": { - "name": "Grid fossil fuel percentage" + "name": "Grid fossil fuel percentage", + "state_attributes": { + "country_code": { + "name": "[%key:component::co2signal::entity::sensor::carbon_intensity::state_attributes::country_code::name%]" + } + } + } + } + }, + "selector": { + "location": { + "options": { + "use_home_location": "Use home location", + "specify_coordinates": "Specify coordinates", + "specify_country_code": "Specify country code" } } } diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 4a105072802..b271644234d 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -1,18 +1,24 @@ """Comelit integration.""" + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PIN, Platform +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import DEFAULT_PORT, DOMAIN from .coordinator import ComelitSerialBridge -PLATFORMS = [Platform.COVER, Platform.LIGHT] +PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Comelit platform.""" - coordinator = ComelitSerialBridge(hass, entry.data[CONF_HOST], entry.data[CONF_PIN]) + coordinator = ComelitSerialBridge( + hass, + entry.data[CONF_HOST], + entry.data.get(CONF_PORT, DEFAULT_PORT), + entry.data[CONF_PIN], + ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index b0c8e5aabe5..b95853edf9d 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -9,13 +9,14 @@ import voluptuous as vol from homeassistant import core, exceptions from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_HOST, CONF_PIN +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv -from .const import _LOGGER, DOMAIN +from .const import _LOGGER, DEFAULT_PORT, DOMAIN DEFAULT_HOST = "192.168.1.252" -DEFAULT_PIN = "111111" +DEFAULT_PIN = 111111 def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: @@ -23,13 +24,14 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: user_input = user_input or {} return vol.Schema( { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): str, + vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, } ) -STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): str}) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int}) async def validate_input( @@ -37,7 +39,7 @@ async def validate_input( ) -> dict[str, str]: """Validate the user input allows us to connect.""" - api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PIN]) + api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) try: await api.login() @@ -58,6 +60,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: ConfigEntry | None _reauth_host: str + _reauth_port: int async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -94,6 +97,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): self.context["entry_id"] ) self._reauth_host = entry_data[CONF_HOST] + self._reauth_port = entry_data.get(CONF_PORT, DEFAULT_PORT) + self.context["title_placeholders"] = {"host": self._reauth_host} return await self.async_step_reauth_confirm() @@ -107,7 +112,12 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await validate_input( - self.hass, {CONF_HOST: self._reauth_host} | user_input + self.hass, + { + CONF_HOST: self._reauth_host, + CONF_PORT: self._reauth_port, + } + | user_input, ) except CannotConnect: errors["base"] = "cannot_connect" @@ -121,6 +131,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): self._reauth_entry, data={ CONF_HOST: self._reauth_host, + CONF_PORT: self._reauth_port, CONF_PIN: user_input[CONF_PIN], }, ) diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index e08caa55f76..57b7f35bc17 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -4,3 +4,4 @@ import logging _LOGGER = logging.getLogger(__package__) DOMAIN = "comelit" +DEFAULT_PORT = 80 diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index a9c281c10c0..d3bc973429b 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -1,11 +1,9 @@ """Support for Comelit.""" -import asyncio from datetime import timedelta from typing import Any -from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject +from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject, exceptions from aiocomelit.const import BRIDGE -import aiohttp from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,13 +19,14 @@ class ComelitSerialBridge(DataUpdateCoordinator): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, host: str, pin: int) -> None: + def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: """Initialize the scanner.""" self._host = host + self._port = port self._pin = pin - self.api = ComeliteSerialBridgeApi(host, pin) + self.api = ComeliteSerialBridgeApi(host, port, pin) super().__init__( hass=hass, @@ -53,32 +52,29 @@ class ComelitSerialBridge(DataUpdateCoordinator): "hw_version": "20003101", } - def platform_device_info( - self, device: ComelitSerialBridgeObject, platform: str - ) -> dr.DeviceInfo: + def platform_device_info(self, device: ComelitSerialBridgeObject) -> dr.DeviceInfo: """Set platform device info.""" return dr.DeviceInfo( identifiers={ - (DOMAIN, f"{self.config_entry.entry_id}-{platform}-{device.index}") + (DOMAIN, f"{self.config_entry.entry_id}-{device.type}-{device.index}") }, via_device=(DOMAIN, self.config_entry.entry_id), name=device.name, - model=f"{BRIDGE} {platform}", + model=f"{BRIDGE} {device.type}", **self.basic_device_info, ) async def _async_update_data(self) -> dict[str, Any]: - """Update router data.""" + """Update device data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) - logged = False try: - logged = await self.api.login() - except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err: + await self.api.login() + except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) + await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err - finally: - if not logged: - raise ConfigEntryAuthFailed + except exceptions.CannotAuthenticate: + raise ConfigEntryAuthFailed return await self.api.get_all_devices() diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 0135fa3984a..4a3c8eed63c 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -4,12 +4,13 @@ from __future__ import annotations from typing import Any from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import COVER, COVER_CLOSE, COVER_OPEN, COVER_STATUS +from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON -from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.components.cover import STATE_CLOSED, CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -25,14 +26,15 @@ async def async_setup_entry( coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available async_add_entities( ComelitCoverEntity(coordinator, device, config_entry.entry_id) for device in coordinator.data[COVER].values() ) -class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): +class ComelitCoverEntity( + CoordinatorEntity[ComelitSerialBridge], RestoreEntity, CoverEntity +): """Cover device.""" _attr_device_class = CoverDeviceClass.SHUTTER @@ -49,16 +51,19 @@ class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): self._api = coordinator.api self._device = device super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, COVER) - # Device doesn't provide a status so we assume CLOSE at startup - self._last_action = COVER_STATUS.index("closing") + self._attr_device_info = coordinator.platform_device_info(device) + # Device doesn't provide a status so we assume UNKNOWN at first startup + self._last_action: int | None = None + self._last_state: str | None = None def _current_action(self, action: str) -> bool: """Return the current cover action.""" - is_moving = self.device_status == COVER_STATUS.index(action) + is_moving = self.device_status == STATE_COVER.index(action) if is_moving: - self._last_action = COVER_STATUS.index(action) + self._last_action = STATE_COVER.index(action) return is_moving @property @@ -67,12 +72,19 @@ class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): return self.coordinator.data[COVER][self._device.index].status @property - def is_closed(self) -> bool: - """Return True if cover is closed.""" - if self.device_status != COVER_STATUS.index("stopped"): + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + + if self._last_state in [None, "unknown"]: + return None + + if self.device_status != STATE_COVER.index("stopped"): return False - return bool(self._last_action == COVER_STATUS.index("closing")) + if self._last_action: + return self._last_action == STATE_COVER.index("closing") + + return self._last_state == STATE_CLOSED @property def is_closing(self) -> bool: @@ -86,16 +98,30 @@ class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._api.cover_move(self._device.index, COVER_CLOSE) + await self._api.set_device_status(COVER, self._device.index, STATE_OFF) async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - await self._api.cover_move(self._device.index, COVER_OPEN) + await self._api.set_device_status(COVER, self._device.index, STATE_ON) async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" if not self.is_closing and not self.is_opening: return - action = COVER_OPEN if self.is_closing else COVER_CLOSE - await self._api.cover_move(self._device.index, action) + action = STATE_OFF if self.is_closing else STATE_ON + await self._api.set_device_status(COVER, self._device.index, action) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle device update.""" + self._last_state = self.state + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + + await super().async_added_to_hass() + + if last_state := await self.async_get_last_state(): + self._last_state = last_state.state diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index a59422f7b04..95906f7ec6e 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import LIGHT, LIGHT_OFF, LIGHT_ON +from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON from homeassistant.components.light import LightEntity from homeassistant.config_entries import ConfigEntry @@ -25,7 +25,6 @@ async def async_setup_entry( coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available async_add_entities( ComelitLightEntity(coordinator, device, config_entry.entry_id) for device in coordinator.data[LIGHT].values() @@ -48,23 +47,25 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): self._api = coordinator.api self._device = device super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = self.coordinator.platform_device_info(device, LIGHT) + self._attr_device_info = coordinator.platform_device_info(device) async def _light_set_state(self, state: int) -> None: """Set desired light state.""" - await self.coordinator.api.light_switch(self._device.index, state) + await self.coordinator.api.set_device_status(LIGHT, self._device.index, state) await self.coordinator.async_request_refresh() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - await self._light_set_state(LIGHT_ON) + await self._light_set_state(STATE_ON) async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - await self._light_set_state(LIGHT_OFF) + """Turn the light off.""" + await self._light_set_state(STATE_OFF) @property def is_on(self) -> bool: - """Return True if entity is on.""" - return self.coordinator.data[LIGHT][self._device.index].status == LIGHT_ON + """Return True if light is on.""" + return self.coordinator.data[LIGHT][self._device.index].status == STATE_ON diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 3e49996e50e..5978f17cfc4 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.0.9"] + "requirements": ["aiocomelit==0.3.0"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py new file mode 100644 index 00000000000..554433fa6ad --- /dev/null +++ b/homeassistant/components/comelit/sensor.py @@ -0,0 +1,82 @@ +"""Support for sensors.""" +from __future__ import annotations + +from typing import Final + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import OTHER + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + +SENSOR_TYPES: Final = ( + SensorEntityDescription( + key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit sensors.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ComelitSensorEntity] = [] + for device in coordinator.data[OTHER].values(): + entities.extend( + ComelitSensorEntity(coordinator, device, config_entry.entry_id, sensor_desc) + for sensor_desc in SENSOR_TYPES + ) + + async_add_entities(entities) + + +class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): + """Sensor device.""" + + _attr_has_entity_name = True + entity_description: SensorEntityDescription + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + description: SensorEntityDescription, + ) -> None: + """Init sensor entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device) + + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Sensor value.""" + return getattr( + self.coordinator.data[OTHER][self._device.index], + self.entity_description.key, + ) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 436fbfd5aec..730674e913a 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -11,6 +11,7 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", "pin": "[%key:common::config_flow::data::pin%]" } } diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py new file mode 100644 index 00000000000..379b936c3bb --- /dev/null +++ b/homeassistant/components/comelit/switch.py @@ -0,0 +1,84 @@ +"""Support for switches.""" +from __future__ import annotations + +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON + +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 +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit switches.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ComelitSwitchEntity] = [] + entities.extend( + ComelitSwitchEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[IRRIGATION].values() + ) + entities.extend( + ComelitSwitchEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[OTHER].values() + ) + async_add_entities(entities) + + +class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): + """Switch device.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + ) -> None: + """Init switch entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device) + if device.type == OTHER: + self._attr_device_class = SwitchDeviceClass.OUTLET + + async def _switch_set_state(self, state: int) -> None: + """Set desired switch state.""" + await self.coordinator.api.set_device_status( + self._device.type, self._device.index, state + ) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._switch_set_state(STATE_ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._switch_set_state(STATE_OFF) + + @property + def is_on(self) -> bool: + """Return True if switch is on.""" + return ( + self.coordinator.data[self._device.type][self._device.index].status + == STATE_ON + ) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 6f536bf4744..e1a051cea33 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -80,6 +80,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema( { vol.Required(CONF_COMMAND): cv.string, vol.Optional(CONF_NAME, default=BINARY_SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, @@ -119,6 +120,7 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -169,8 +171,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: Event | ServiceCall) -> None: """Reload Command Line.""" - reload_config = await async_integration_yaml_config(hass, "command_line") - reset_platforms = async_get_platforms(hass, "command_line") + reload_config = await async_integration_yaml_config(hass, DOMAIN) + reset_platforms = async_get_platforms(hass, DOMAIN) for reset_platform in reset_platforms: _LOGGER.debug("Reload resetting platform: %s", reset_platform.domain) await reset_platform.async_reset() diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 1d6ee9046e8..3ccd0bd1503 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -16,6 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, + CONF_ICON, CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, @@ -86,6 +87,7 @@ async def async_setup_platform( device_class: BinarySensorDeviceClass | None = binary_sensor_config.get( CONF_DEVICE_CLASS ) + icon: Template | None = binary_sensor_config.get(CONF_ICON) value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) @@ -100,6 +102,7 @@ async def async_setup_platform( CONF_UNIQUE_ID: unique_id, CONF_NAME: Template(name, hass), CONF_DEVICE_CLASS: device_class, + CONF_ICON: icon, } async_add_entities( diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 20e00ec11ed..a0e0d1877fa 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -112,7 +112,7 @@ def websocket_get_entity( return connection.send_message( - websocket_api.result_message(msg["id"], _entry_ext_dict(entry)) + websocket_api.result_message(msg["id"], entry.extended_dict) ) @@ -138,7 +138,7 @@ def websocket_get_entities( entries: dict[str, dict[str, Any] | None] = {} for entity_id in entity_ids: entry = registry.entities.get(entity_id) - entries[entity_id] = _entry_ext_dict(entry) if entry else None + entries[entity_id] = entry.extended_dict if entry else None connection.send_message(websocket_api.result_message(msg["id"], entries)) @@ -248,7 +248,7 @@ def websocket_update_entity( ) return - result: dict[str, Any] = {"entity_entry": _entry_ext_dict(entity_entry)} + result: dict[str, Any] = {"entity_entry": entity_entry.extended_dict} if "disabled_by" in changes and changes["disabled_by"] is None: # Enabling an entity requires a config entry reload, or HA restart if ( @@ -289,15 +289,3 @@ def websocket_remove_entity( registry.async_remove(msg["entity_id"]) connection.send_message(websocket_api.result_message(msg["id"])) - - -@callback -def _entry_ext_dict(entry: er.RegistryEntry) -> dict[str, Any]: - """Convert entry to API format.""" - data = entry.as_partial_dict - data["aliases"] = entry.aliases - data["capabilities"] = entry.capabilities - data["device_class"] = entry.device_class - data["original_device_class"] = entry.original_device_class - data["original_icon"] = entry.original_icon - return data diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index f11dda15a4e..1b4d346082a 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.10.2"] + "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.10.16"] } diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json index a53c34fb0de..ff3a41d9c09 100644 --- a/homeassistant/components/cpuspeed/manifest.json +++ b/homeassistant/components/cpuspeed/manifest.json @@ -1,10 +1,10 @@ { "domain": "cpuspeed", "name": "CPU Speed", - "codeowners": ["@fabaff", "@frenck"], + "codeowners": ["@fabaff"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cpuspeed", "integration_type": "device", "iot_class": "local_push", - "requirements": ["py-cpuinfo==8.0.0"] + "requirements": ["py-cpuinfo==9.0.0"] } diff --git a/homeassistant/components/cribl/__init__.py b/homeassistant/components/cribl/__init__.py new file mode 100644 index 00000000000..0f5be79f583 --- /dev/null +++ b/homeassistant/components/cribl/__init__.py @@ -0,0 +1 @@ +"""Cribl virtual integration for Home Assistant.""" diff --git a/homeassistant/components/cribl/manifest.json b/homeassistant/components/cribl/manifest.json new file mode 100644 index 00000000000..f870bd0b8c6 --- /dev/null +++ b/homeassistant/components/cribl/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "cribl", + "name": "Cribl", + "integration_type": "virtual", + "supported_by": "splunk" +} diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index eda7976e572..cc79f2ae233 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -168,9 +168,12 @@ async def async_migrate_unique_id( ent_reg, duplicate.id, True ) for entity in duplicate_entities: - ent_reg.async_remove(entity.entity_id) + if entity.config_entry_id == config_entry.entry_id: + ent_reg.async_remove(entity.entity_id) - dev_reg.async_remove_device(duplicate.id) + dev_reg.async_update_device( + duplicate.id, remove_config_entry_id=config_entry.entry_id + ) # Migrate devices for device_entry in dr.async_entries_for_config_entry( diff --git a/homeassistant/components/datetime/services.yaml b/homeassistant/components/datetime/services.yaml index fb6f798e9bd..ddd733837a8 100644 --- a/homeassistant/components/datetime/services.yaml +++ b/homeassistant/components/datetime/services.yaml @@ -5,6 +5,6 @@ set_value: fields: datetime: required: true - example: "2022/11/01 22:15" + example: "2023-10-07T21:35:22" selector: datetime: diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 47ca1eda0d8..dc2ed04b4ed 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -38,7 +38,27 @@ from .deconz_device import DeconzDevice from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_GROUP = "is_deconz_group" -EFFECT_TO_DECONZ = {EFFECT_COLORLOOP: LightEffect.COLOR_LOOP, "None": LightEffect.NONE} +EFFECT_TO_DECONZ = { + EFFECT_COLORLOOP: LightEffect.COLOR_LOOP, + "None": LightEffect.NONE, + # Specific to Lidl christmas light + "carnival": LightEffect.CARNIVAL, + "collide": LightEffect.COLLIDE, + "fading": LightEffect.FADING, + "fireworks": LightEffect.FIREWORKS, + "flag": LightEffect.FLAG, + "glow": LightEffect.GLOW, + "rainbow": LightEffect.RAINBOW, + "snake": LightEffect.SNAKE, + "snow": LightEffect.SNOW, + "sparkles": LightEffect.SPARKLES, + "steady": LightEffect.STEADY, + "strobe": LightEffect.STROBE, + "twinkle": LightEffect.TWINKLE, + "updown": LightEffect.UPDOWN, + "vintage": LightEffect.VINTAGE, + "waves": LightEffect.WAVES, +} FLASH_TO_DECONZ = {FLASH_SHORT: LightAlert.SHORT, FLASH_LONG: LightAlert.LONG} DECONZ_TO_COLOR_MODE = { @@ -47,6 +67,25 @@ DECONZ_TO_COLOR_MODE = { LightColorMode.XY: ColorMode.XY, } +TS0601_EFFECTS = [ + "carnival", + "collide", + "fading", + "fireworks", + "flag", + "glow", + "rainbow", + "snake", + "snow", + "sparkles", + "steady", + "strobe", + "twinkle", + "updown", + "vintage", + "waves", +] + _LightDeviceT = TypeVar("_LightDeviceT", bound=Group | Light) @@ -161,6 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_effect_list = [EFFECT_COLORLOOP] + if device.model_id == "TS0601": + self._attr_effect_list += TS0601_EFFECTS @property def color_mode(self) -> str | None: diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index b40e1ede232..98226d68030 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -48,6 +48,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.UPDATE, Platform.VACUUM, Platform.WATER_HEATER, + Platform.WEATHER, ] COMPONENTS_WITH_DEMO_PLATFORM = [ @@ -56,7 +57,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.NOTIFY, Platform.IMAGE_PROCESSING, Platform.DEVICE_TRACKER, - Platform.WEATHER, ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py index c63729f2cd6..d1a56112497 100644 --- a/homeassistant/components/demo/air_quality.py +++ b/homeassistant/components/demo/air_quality.py @@ -5,19 +5,6 @@ from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Air Quality.""" - async_add_entities( - [DemoAirQuality("Home", 14, 23, 100), DemoAirQuality("Office", 4, 16, None)] - ) async def async_setup_entry( @@ -26,7 +13,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) + async_add_entities( + [DemoAirQuality("Home", 14, 23, 100), DemoAirQuality("Office", 4, 16, None)] + ) class DemoAirQuality(AirQualityEntity): diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index 3a94aaa7c29..1c15e9d5b7e 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -19,16 +19,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo alarm control panel platform.""" + """Set up the Demo config entry.""" async_add_entities( [ ManualAlarm( # type:ignore[no-untyped-call] @@ -75,12 +73,3 @@ async def async_setup_platform( ) ] ) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 6639c125653..1e585b12acd 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ClimateEntity, @@ -258,6 +259,8 @@ class DemoClimate(ClimateEntity): ): self._target_temperature_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) self._target_temperature_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + self._hvac_mode = hvac_mode self.async_write_ha_state() async def async_set_humidity(self, humidity: int) -> None: diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 5c8cc849285..211389a5466 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -7,7 +7,6 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType PRESET_MODE_AUTO = "auto" PRESET_MODE_SMART = "smart" @@ -20,13 +19,12 @@ FULL_SUPPORT = ( LIMITED_SUPPORT = FanEntityFeature.SET_SPEED -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the demo fan platform.""" + """Set up the Demo config entry.""" async_add_entities( [ DemoPercentageFan( @@ -88,15 +86,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class BaseDemoFan(FanEntity): """A demonstration fan component that uses legacy fan speeds.""" diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py index 2e16a04e171..a63e3e1983f 100644 --- a/homeassistant/components/demo/humidifier.py +++ b/homeassistant/components/demo/humidifier.py @@ -12,18 +12,16 @@ from homeassistant.components.humidifier import ( 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_FLAGS = HumidifierEntityFeature(0) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo humidifier devices.""" + """Set up the Demo humidifier devices config entry.""" async_add_entities( [ DemoHumidifier( @@ -52,15 +50,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo humidifier devices config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoHumidifier(HumidifierEntity): """Representation of a demo humidifier device.""" diff --git a/homeassistant/components/demo/image_processing.py b/homeassistant/components/demo/image_processing.py index 21322f49718..71ea9d97bf6 100644 --- a/homeassistant/components/demo/image_processing.py +++ b/homeassistant/components/demo/image_processing.py @@ -10,14 +10,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -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 demo image processing platform.""" - add_entities( + async_add_entities( [ DemoImageProcessingFace("camera.demo_camera", "Demo Face"), ] diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index e75c2074aab..3a6780ce30e 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -15,35 +15,24 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType LOCK_UNLOCK_DELAY = 2 # Used to give a realistic lock/unlock experience in frontend -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo lock platform.""" - async_add_entities( - [ - DemoLock("Front Door", STATE_LOCKED), - DemoLock("Kitchen Door", STATE_UNLOCKED), - DemoLock("Poorly Installed Door", STATE_UNLOCKED, False, True), - DemoLock("Openable Lock", STATE_LOCKED, True), - ] - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) + async_add_entities( + [ + DemoLock("Front Door", STATE_LOCKED), + DemoLock("Kitchen Door", STATE_UNLOCKED), + DemoLock("Poorly Installed Door", STATE_UNLOCKED, False, True), + DemoLock("Openable Lock", STATE_LOCKED, True), + ] + ) class DemoLock(LockEntity): diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 9d335c34cdb..35bd35a2245 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -15,17 +15,15 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the media player demo platform.""" + """Set up the Demo config entry.""" async_add_entities( [ DemoYoutubePlayer( @@ -44,15 +42,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - SOUND_MODE_LIST = ["Music", "Movie"] DEFAULT_SOUND_MODE = "Music" diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index f2c1ce11b0a..40df72b073b 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -9,7 +9,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType async def async_setup_entry( @@ -18,17 +17,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - setup_platform(hass, {}, async_add_entities) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities_callback: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the demo remotes.""" - add_entities_callback( + async_add_entities( [ DemoRemote("Remote One", False, None), DemoRemote("Remote Two", True, "mdi:remote"), diff --git a/homeassistant/components/demo/siren.py b/homeassistant/components/demo/siren.py index 0720114861c..3b3c3dfc610 100644 --- a/homeassistant/components/demo/siren.py +++ b/homeassistant/components/demo/siren.py @@ -7,18 +7,16 @@ from homeassistant.components.siren import SirenEntity, SirenEntityFeature 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_FLAGS = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo siren devices.""" + """Set up the Demo siren devices config entry.""" async_add_entities( [ DemoSiren(name="Siren"), @@ -32,15 +30,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo siren devices config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoSiren(SirenEntity): """Representation of a demo siren device.""" diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 11b69272775..83216ebdba6 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -19,7 +19,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF @@ -79,16 +78,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo vacuums.""" async_add_entities( [ DemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py index 0ab175691f8..beb46c5d8ad 100644 --- a/homeassistant/components/demo/water_heater.py +++ b/homeassistant/components/demo/water_heater.py @@ -11,7 +11,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_FLAGS_HEATER = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE @@ -21,13 +20,12 @@ SUPPORT_FLAGS_HEATER = ( ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo water_heater devices.""" + """Set up the Demo config entry.""" async_add_entities( [ DemoWaterHeater( @@ -40,15 +38,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoWaterHeater(WaterHeaterEntity): """Representation of a demo water_heater device.""" diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index 758b5075041..a990e26c658 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -27,7 +27,6 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util CONDITION_CLASSES: dict[str, list[str]] = { @@ -61,17 +60,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - setup_platform(hass, {}, async_add_entities) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo weather.""" - add_entities( + async_add_entities( [ DemoWeather( "South", diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index e7c7a44117a..92fff3730a9 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -6,6 +6,7 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_NAME, CONF_SOURCE, UnitOfTime from homeassistant.helpers import selector @@ -23,7 +24,6 @@ from .const import ( ) UNIT_PREFIXES = [ - selector.SelectOptionDict(value="none", label="none"), selector.SelectOptionDict(value="n", label="n (nano)"), selector.SelectOptionDict(value="µ", label="µ (micro)"), selector.SelectOptionDict(value="m", label="m (milli)"), @@ -51,7 +51,7 @@ OPTIONS_SCHEMA = vol.Schema( ), ), vol.Required(CONF_TIME_WINDOW): selector.DurationSelector(), - vol.Required(CONF_UNIT_PREFIX, default="none"): selector.SelectSelector( + vol.Optional(CONF_UNIT_PREFIX): selector.SelectSelector( selector.SelectSelectorConfig(options=UNIT_PREFIXES), ), vol.Required(CONF_UNIT_TIME, default=UnitOfTime.HOURS): selector.SelectSelector( @@ -66,7 +66,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_SOURCE): selector.EntitySelector( - selector.EntitySelectorConfig(domain=SENSOR_DOMAIN), + selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN]), ), } ).extend(OPTIONS_SCHEMA.schema) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index ba77d2a3d4b..73d297d7541 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -116,8 +116,8 @@ async def async_setup_entry( else: device_info = None - unit_prefix = config_entry.options[CONF_UNIT_PREFIX] - if unit_prefix == "none": + if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": + # Before we had support for optional selectors, "none" was used for selecting nothing unit_prefix = None derivative_sensor = DerivativeSensor( diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index ef36d46d8b9..4b66c893d57 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -25,7 +25,7 @@ "step": { "init": { "data": { - "name": "[%key:component::derivative::config::step::user::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "round": "[%key:component::derivative::config::step::user::data::round%]", "source": "[%key:component::derivative::config::step::user::data::source%]", "time_window": "[%key:component::derivative::config::step::user::data::time_window%]", diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 83c599bc65d..a00455293f6 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -5,9 +5,9 @@ from typing import cast import voluptuous as vol -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, Platform +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -55,31 +55,42 @@ async def async_validate_device_automation_config( platform = await async_get_device_automation_platform( hass, validated_config[CONF_DOMAIN], automation_type ) + + # Make sure the referenced device and optional entity exist + device_registry = dr.async_get(hass) + if not (device := device_registry.async_get(validated_config[CONF_DEVICE_ID])): + # The device referenced by the device automation does not exist + raise InvalidDeviceAutomationConfig( + f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" + ) + if entity_id := validated_config.get(CONF_ENTITY_ID): + try: + er.async_validate_entity_id(er.async_get(hass), entity_id) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig( + f"Unknown entity '{entity_id}'" + ) from err + if not hasattr(platform, DYNAMIC_VALIDATOR[automation_type]): # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config) ) - # Bypass checks for entity platforms + # Devices are not linked to config entries from entity platform domains, skip + # the checks below which look for a config entry matching the device automation + # domain if ( automation_type == DeviceAutomationType.ACTION and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS ): + # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, await getattr(platform, DYNAMIC_VALIDATOR[automation_type])(hass, config), ) - # Only call the dynamic validator if the referenced device exists and the relevant - # config entry is loaded - registry = dr.async_get(hass) - if not (device := registry.async_get(validated_config[CONF_DEVICE_ID])): - # The device referenced by the device automation does not exist - raise InvalidDeviceAutomationConfig( - f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" - ) - + # Find a config entry with the same domain as the device automation device_config_entry = None for entry_id in device.config_entries: if ( @@ -91,7 +102,7 @@ async def async_validate_device_automation_config( break if not device_config_entry: - # The config entry referenced by the device automation does not exist + # There's no config entry with the same domain as the device automation raise InvalidDeviceAutomationConfig( f"Device '{validated_config[CONF_DEVICE_ID]}' has no config entry from " f"domain '{validated_config[CONF_DOMAIN]}'" diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index b428018cd9e..7c12a2d8777 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -12,6 +12,7 @@ import attr import voluptuous as vol from homeassistant import util +from homeassistant.backports.functools import cached_property from homeassistant.components import zone from homeassistant.config import async_log_exception, load_yaml_config_file from homeassistant.const import ( @@ -262,7 +263,7 @@ class DeviceTrackerPlatform: platform: ModuleType = attr.ib() config: dict = attr.ib() - @property + @cached_property def type(self) -> str | None: """Return platform type.""" methods, platform_type = self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 94e848fe8af..0fee65d57b6 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -54,6 +55,7 @@ async def async_setup_entry( # noqa: C901 hass.data.setdefault(DOMAIN, {}) zeroconf_instance = await zeroconf.async_get_async_instance(hass) async_client = get_async_client(hass) + device_registry = dr.async_get(hass) try: device = Device( @@ -73,6 +75,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_firmware_available() -> UpdateFirmwareCheck: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_check_firmware_available() except DeviceUnavailable as err: @@ -81,6 +84,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_connected_plc_devices() -> LogicalNetwork: """Fetch data from API endpoint.""" assert device.plcnet + update_sw_version(device_registry, device) try: return await device.plcnet.async_get_network_overview() except DeviceUnavailable as err: @@ -89,6 +93,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_guest_wifi_status() -> WifiGuestAccessGet: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_get_wifi_guest_access() except DeviceUnavailable as err: @@ -99,6 +104,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_led_status() -> bool: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_get_led_setting() except DeviceUnavailable as err: @@ -107,6 +113,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_get_wifi_connected_station() except DeviceUnavailable as err: @@ -115,6 +122,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_get_wifi_neighbor_access_points() except DeviceUnavailable as err: @@ -211,3 +219,16 @@ def platforms(device: Device) -> set[Platform]: if device.device and "update" in device.device.features: supported_platforms.add(Platform.UPDATE) return supported_platforms + + +@callback +def update_sw_version(device_registry: dr.DeviceRegistry, device: Device) -> None: + """Update device registry with new firmware version.""" + if ( + device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, str(device.serial_number))} + ) + ) and device_entry.sw_version != device.firmware_version: + device_registry.async_update_device( + device_id=device_entry.id, sw_version=device.firmware_version + ) diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 56a1043d126..53c502dc811 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -52,7 +52,7 @@ class DevoloEntity(Entity): identifiers={(DOMAIN, str(device.serial_number))}, manufacturer="devolo", model=device.product, - name=entry.title, + serial_number=device.serial_number, sw_version=device.firmware_version, ) self._attr_translation_key = self.entity_description.key diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index e035661db10..b3dee2d82a0 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -60,15 +60,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" self.existing_entry = await self.async_set_unique_id(self.context["unique_id"]) - if entry_data is None: - return self.async_show_form( - step_id="reauth", - data_schema=make_schema( - self.existing_entry.data[CONF_EMAIL] or "", - self.existing_entry.data[CONF_PASSWORD] or "", - ), - ) - return await self._validate_and_save(entry_data, step_id="reauth") async def _validate_and_save( diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index 4223318ed93..f70a531215e 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pydiscovergy==2.0.3"] + "requirements": ["pydiscovergy==2.0.5"] } diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 5b8fb864987..0f5ace28dd7 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -219,8 +219,9 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, meter.meter_id)}, name=f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", - model=f"{meter.type} {meter.full_serial_number}", + model=meter.type, manufacturer=MANUFACTURER, + serial_number=meter.full_serial_number, ) @property diff --git a/homeassistant/components/discovergy/system_health.py b/homeassistant/components/discovergy/system_health.py index 2baeb0e5f6e..61fe4099596 100644 --- a/homeassistant/components/discovergy/system_health.py +++ b/homeassistant/components/discovergy/system_health.py @@ -1,4 +1,6 @@ """Provide info to system health.""" +from typing import Any + from pydiscovergy.const import API_BASE from homeassistant.components import system_health @@ -13,7 +15,7 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" return { "api_endpoint_reachable": system_health.async_check_can_reach_url( diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 12397eb8990..e49f525d0c2 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==10.0.1"] + "requirements": ["pydoods==1.0.2", "Pillow==10.1.0"] } diff --git a/homeassistant/components/doorbird/util.py b/homeassistant/components/doorbird/util.py index b3b62a4985a..c307a125b8d 100644 --- a/homeassistant/components/doorbird/util.py +++ b/homeassistant/components/doorbird/util.py @@ -11,7 +11,7 @@ from .models import DoorBirdData def get_mac_address_from_door_station_info(door_station_info: dict[str, Any]) -> str: """Get the mac address depending on the device type.""" - return door_station_info.get("PRIMARY_MAC_ADDR", door_station_info["WIFI_MAC_ADDR"]) + return door_station_info.get("PRIMARY_MAC_ADDR", door_station_info["WIFI_MAC_ADDR"]) # type: ignore[no-any-return] def get_door_station_by_token( diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index 15bcf3f9ddc..480f021b126 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 5e1a54aedc4..7bc0247aea6 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -34,3 +34,6 @@ DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} DSMR_PROTOCOL = "dsmr_protocol" RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol" + +# Temp obis until sensors replaced by mbus variants +BELGIUM_5MIN_GAS_METER_READING = r"\d-\d:24\.2\.3.+?\r\n" diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 3fc81d2f8e7..b3f59a15b80 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==0.33"] + "requirements": ["dsmr-parser==1.3.0"] } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index e4f9d0e9ab9..99af30b8111 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from asyncio import CancelledError +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta @@ -34,11 +35,16 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle from .const import ( + BELGIUM_5MIN_GAS_METER_READING, CONF_DSMR_VERSION, CONF_PRECISION, CONF_PROTOCOL, @@ -57,6 +63,8 @@ from .const import ( LOGGER, ) +EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}" + UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS} @@ -191,7 +199,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="short_power_failure_count", translation_key="short_power_failure_count", obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", entity_category=EntityCategory.DIAGNOSTIC, @@ -200,7 +208,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="long_power_failure_count", translation_key="long_power_failure_count", obis_reference=obis_references.LONG_POWER_FAILURE_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", entity_category=EntityCategory.DIAGNOSTIC, @@ -209,7 +217,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_sag_l1_count", translation_key="voltage_sag_l1_count", obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -217,7 +225,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_sag_l2_count", translation_key="voltage_sag_l2_count", obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -225,7 +233,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_sag_l3_count", translation_key="voltage_sag_l3_count", obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -233,7 +241,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_swell_l1_count", translation_key="voltage_swell_l1_count", obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", entity_category=EntityCategory.DIAGNOSTIC, @@ -242,7 +250,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_swell_l2_count", translation_key="voltage_swell_l2_count", obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", entity_category=EntityCategory.DIAGNOSTIC, @@ -251,7 +259,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_swell_l3_count", translation_key="voltage_swell_l3_count", obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", entity_category=EntityCategory.DIAGNOSTIC, @@ -325,7 +333,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="max_current_per_phase", obis_reference=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE, dsmr_versions={"5B"}, - device_class=SensorDeviceClass.POWER, + device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -348,6 +356,22 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + DSMRSensorEntityDescription( + key="belgium_current_average_demand", + translation_key="current_average_demand", + obis_reference=obis_references.BELGIUM_CURRENT_AVERAGE_DEMAND, + dsmr_versions={"5B"}, + force_update=True, + device_class=SensorDeviceClass.POWER, + ), + DSMRSensorEntityDescription( + key="belgium_maximum_demand_current_month", + translation_key="maximum_demand_current_month", + obis_reference=obis_references.BELGIUM_MAXIMUM_DEMAND_MONTH, + dsmr_versions={"5B"}, + force_update=True, + device_class=SensorDeviceClass.POWER, + ), DSMRSensorEntityDescription( key="hourly_gas_meter_reading", translation_key="gas_meter_reading", @@ -361,7 +385,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key="belgium_5min_gas_meter_reading", translation_key="gas_meter_reading", - obis_reference=obis_references.BELGIUM_5MIN_GAS_METER_READING, + obis_reference=BELGIUM_5MIN_GAS_METER_READING, dsmr_versions={"5B"}, is_gas=True, force_update=True, @@ -386,17 +410,58 @@ async def async_setup_entry( ) -> None: """Set up the DSMR sensor.""" dsmr_version = entry.data[CONF_DSMR_VERSION] - entities = [ - DSMREntity(description, entry) - for description in SENSORS - if ( - description.dsmr_versions is None - or dsmr_version in description.dsmr_versions - ) - and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data) - ] - async_add_entities(entities) + entities: list[DSMREntity] = [] + initialized: bool = False + add_entities_handler: Callable[..., None] | None + @callback + def init_async_add_entities(telegram: dict[str, DSMRObject]) -> None: + """Add the sensor entities after the first telegram was received.""" + nonlocal add_entities_handler + assert add_entities_handler is not None + add_entities_handler() + add_entities_handler = None + + def device_class_and_uom( + telegram: dict[str, DSMRObject], + entity_description: DSMRSensorEntityDescription, + ) -> tuple[SensorDeviceClass | None, str | None]: + """Get native unit of measurement from telegram,.""" + dsmr_object = telegram[entity_description.obis_reference] + uom: str | None = getattr(dsmr_object, "unit") or None + with suppress(ValueError): + if entity_description.device_class == SensorDeviceClass.GAS and ( + enery_uom := UnitOfEnergy(str(uom)) + ): + return (SensorDeviceClass.ENERGY, enery_uom) + if uom in UNIT_CONVERSION: + return (entity_description.device_class, UNIT_CONVERSION[uom]) + return (entity_description.device_class, uom) + + entities.extend( + [ + DSMREntity( + description, + entry, + telegram, + *device_class_and_uom( + telegram, description + ), # type: ignore[arg-type] + ) + for description in SENSORS + if ( + description.dsmr_versions is None + or dsmr_version in description.dsmr_versions + ) + and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data) + and description.obis_reference in telegram + ] + ) + async_add_entities(entities) + + add_entities_handler = async_dispatcher_connect( + hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), init_async_add_entities + ) min_time_between_updates = timedelta( seconds=entry.options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE) ) @@ -404,10 +469,17 @@ async def async_setup_entry( @Throttle(min_time_between_updates) def update_entities_telegram(telegram: dict[str, DSMRObject] | None) -> None: """Update entities with latest telegram and trigger state update.""" + nonlocal initialized # Make all device entities aware of new telegram for entity in entities: entity.update_data(telegram) + if not initialized and telegram: + initialized = True + async_dispatcher_send( + hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), telegram + ) + # Creates an asyncio.Protocol factory for reading DSMR telegrams from # serial and calls update_entities_telegram to update entities on arrival protocol = entry.data.get(CONF_PROTOCOL, DSMR_PROTOCOL) @@ -524,6 +596,8 @@ async def async_setup_entry( @callback async def _async_stop(_: Event) -> None: + if add_entities_handler is not None: + add_entities_handler() task.cancel() # Make sure task is cancelled on shutdown (or tests complete) @@ -543,12 +617,19 @@ class DSMREntity(SensorEntity): _attr_should_poll = False def __init__( - self, entity_description: DSMRSensorEntityDescription, entry: ConfigEntry + self, + entity_description: DSMRSensorEntityDescription, + entry: ConfigEntry, + telegram: dict[str, DSMRObject], + device_class: SensorDeviceClass, + native_unit_of_measurement: str | None, ) -> None: """Initialize entity.""" self.entity_description = entity_description + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = native_unit_of_measurement self._entry = entry - self.telegram: dict[str, DSMRObject] | None = {} + self.telegram: dict[str, DSMRObject] | None = telegram device_serial = entry.data[CONF_SERIAL_ID] device_name = DEVICE_NAME_ELECTRICITY @@ -592,21 +673,6 @@ class DSMREntity(SensorEntity): """Entity is only available if there is a telegram.""" return self.telegram is not None - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the device class of this entity.""" - device_class = super().device_class - - # Override device class for gas sensors providing energy units, like - # kWh, MWh, GJ, etc. In those cases, the class should be energy, not gas - with suppress(ValueError): - if device_class == SensorDeviceClass.GAS and UnitOfEnergy( - str(self.native_unit_of_measurement) - ): - return SensorDeviceClass.ENERGY - - return device_class - @property def native_value(self) -> StateType: """Return the state of sensor, if available, translate if needed.""" @@ -627,14 +693,6 @@ class DSMREntity(SensorEntity): return value - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of this entity, if any.""" - unit_of_measurement = self.get_dsmr_object_attr("unit") - if unit_of_measurement in UNIT_CONVERSION: - return UNIT_CONVERSION[unit_of_measurement] - return unit_of_measurement - @staticmethod def translate_tariff(value: str, dsmr_version: str) -> str | None: """Convert 2/1 to normal/low depending on DSMR version.""" diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 7dc44e47a98..5f0568e2905 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -76,6 +76,12 @@ "gas_meter_reading": { "name": "Gas consumption" }, + "current_average_demand": { + "name": "Current average demand" + }, + "maximum_demand_current_month": { + "name": "Maximum demand current month" + }, "instantaneous_active_power_l1_negative": { "name": "Power production phase L1" }, diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 33bba375fd3..f12b2ad72bc 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -141,6 +141,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( translation_key="gas_meter_usage", entity_registry_enabled_default=False, icon="mdi:fire", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -283,6 +284,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/gas", translation_key="daily_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -460,6 +462,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-month/gas", translation_key="current_month_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -538,6 +541,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-year/gas", translation_key="current_year_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py index a1638ce4055..5867e2d634e 100644 --- a/homeassistant/components/duotecno/binary_sensor.py +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -1,6 +1,6 @@ """Support for Duotecno binary sensors.""" -from duotecno.unit import ControlUnit +from duotecno.unit import ControlUnit, VirtualUnit from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry @@ -19,14 +19,15 @@ async def async_setup_entry( """Set up Duotecno binary sensor on config_entry.""" cntrl = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DuotecnoBinarySensor(channel) for channel in cntrl.get_units("ControlUnit") + DuotecnoBinarySensor(channel) + for channel in cntrl.get_units(["ControlUnit", "VirtualUnit"]) ) class DuotecnoBinarySensor(DuotecnoEntity, BinarySensorEntity): """Representation of a DuotecnoBinarySensor.""" - _unit: ControlUnit + _unit: ControlUnit | VirtualUnit @property def is_on(self) -> bool: diff --git a/homeassistant/components/duotecno/config_flow.py b/homeassistant/components/duotecno/config_flow.py index 37087d4ea1a..6f08b025835 100644 --- a/homeassistant/components/duotecno/config_flow.py +++ b/homeassistant/components/duotecno/config_flow.py @@ -34,6 +34,9 @@ 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") + errors: dict[str, str] = {} if user_input is not None: try: diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 96f76517a92..f6482791292 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", + "quality_scale": "silver", "requirements": ["pyDuotecno==2023.10.1"] } diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index a00647993a8..93a545d31dc 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -12,7 +12,8 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "entity": { diff --git a/homeassistant/components/eastron/__init__.py b/homeassistant/components/eastron/__init__.py new file mode 100644 index 00000000000..1def36bc1cf --- /dev/null +++ b/homeassistant/components/eastron/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Eastron.""" diff --git a/homeassistant/components/eastron/manifest.json b/homeassistant/components/eastron/manifest.json new file mode 100644 index 00000000000..5496c2645c7 --- /dev/null +++ b/homeassistant/components/eastron/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "eastron", + "name": "Eastron", + "integration_type": "virtual", + "supported_by": "homewizard" +} diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 3472ca231e9..26b04929a45 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.20"] + "requirements": ["pyeconet==0.1.22"] } diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 8d5411e9e2e..6d048cc423d 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, UV_INDEX, + EntityCategory, UnitOfElectricPotential, UnitOfIrradiance, UnitOfLength, @@ -94,12 +95,14 @@ ECOWITT_SENSORS_MAPPING: Final = { device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), EcoWittSensorTypes.BATTERY_VOLTAGE: SensorEntityDescription( key="BATTERY_VOLTAGE", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), EcoWittSensorTypes.CO2_PPM: SensorEntityDescription( key="CO2_PPM", diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index b8066f2eb31..ab5eff3b60f 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -1,222 +1,37 @@ -"""Support for Eight smart mattress covers and mattresses.""" +"""The Eight Sleep integration.""" from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta -import logging - -from pyeight.eight import EightSleep -from pyeight.exceptions import RequestError -from pyeight.user import EightUser -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_HW_VERSION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SW_VERSION, - CONF_PASSWORD, - CONF_USERNAME, - Platform, -) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo, async_get -from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN, NAME_MAP - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] - -HEAT_SCAN_INTERVAL = timedelta(seconds=60) -USER_SCAN_INTERVAL = timedelta(seconds=300) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ), - }, - extra=vol.ALLOW_EXTRA, -) +DOMAIN = "eight_sleep" -@dataclass -class EightSleepConfigEntryData: - """Data used for all entities for a given config entry.""" - - api: EightSleep - heat_coordinator: DataUpdateCoordinator - user_coordinator: DataUpdateCoordinator - - -def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str: - """Get the device's unique ID.""" - unique_id = eight.device_id - assert unique_id - if user_obj: - unique_id = f"{unique_id}.{user_obj.user_id}.{user_obj.side}" - return unique_id - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Old set up method for the Eight Sleep component.""" - if DOMAIN in config: - _LOGGER.warning( - "Your Eight Sleep configuration has been imported into the UI; " - "please remove it from configuration.yaml as support for it " - "will be removed in a future release" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the Eight Sleep config entry.""" - eight = EightSleep( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - hass.config.time_zone, - client_session=async_get_clientsession(hass), - ) - - # Authenticate, build sensors - try: - success = await eight.start() - except RequestError as err: - raise ConfigEntryNotReady from err - if not success: - # Authentication failed, cannot continue - return False - - heat_coordinator: DataUpdateCoordinator = DataUpdateCoordinator( +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: + """Set up Eight Sleep from a config entry.""" + ir.async_create_issue( hass, - _LOGGER, - name=f"{DOMAIN}_heat", - update_interval=HEAT_SCAN_INTERVAL, - update_method=eight.update_device_data, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/eight_sleep" + }, ) - user_coordinator: DataUpdateCoordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{DOMAIN}_user", - update_interval=USER_SCAN_INTERVAL, - update_method=eight.update_user_data, - ) - await heat_coordinator.async_config_entry_first_refresh() - await user_coordinator.async_config_entry_first_refresh() - - if not eight.users: - # No users, cannot continue - return False - - dev_reg = async_get(hass) - assert eight.device_data - device_data = { - ATTR_MANUFACTURER: "Eight Sleep", - ATTR_MODEL: eight.device_data.get("modelString", UNDEFINED), - ATTR_HW_VERSION: eight.device_data.get("sensorInfo", {}).get( - "hwRevision", UNDEFINED - ), - ATTR_SW_VERSION: eight.device_data.get("firmwareVersion", UNDEFINED), - } - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, _get_device_unique_id(eight))}, - name=f"{entry.data[CONF_USERNAME]}'s Eight Sleep", - **device_data, - ) - for user in eight.users.values(): - assert user.user_profile - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, _get_device_unique_id(eight, user))}, - name=f"{user.user_profile['firstName']}'s Eight Sleep Side", - via_device=(DOMAIN, _get_device_unique_id(eight)), - **device_data, - ) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EightSleepConfigEntryData( - eight, heat_coordinator, user_coordinator - ) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - # stop the API before unloading everything - config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] - await config_entry_data.api.stop() - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return unload_ok - - -class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): - """The base Eight Sleep entity class.""" - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str | None, - sensor: str, - ) -> None: - """Initialize the data object.""" - super().__init__(coordinator) - self._config_entry = entry - self._eight = eight - self._user_id = user_id - self._sensor = sensor - self._user_obj: EightUser | None = None - if user_id: - self._user_obj = self._eight.users[user_id] - - mapped_name = NAME_MAP.get(sensor, sensor.replace("_", " ").title()) - if self._user_obj is not None: - assert self._user_obj.user_profile - name = f"{self._user_obj.user_profile['firstName']}'s {mapped_name}" - self._attr_name = name - else: - self._attr_name = f"Eight Sleep {mapped_name}" - unique_id = f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}" - self._attr_unique_id = unique_id - identifiers = {(DOMAIN, _get_device_unique_id(eight, self._user_obj))} - self._attr_device_info = DeviceInfo(identifiers=identifiers) - - async def async_heat_set(self, target: int, duration: int) -> None: - """Handle eight sleep service calls.""" - if self._user_obj is None: - raise HomeAssistantError( - "This entity does not support the heat set service." - ) - - await self._user_obj.set_heating_level(target, duration) - config_entry_data: EightSleepConfigEntryData = self.hass.data[DOMAIN][ - self._config_entry.entry_id - ] - await config_entry_data.heat_coordinator.async_request_refresh() + return True diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py deleted file mode 100644 index 7ad1b882008..00000000000 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Support for Eight Sleep binary sensors.""" -from __future__ import annotations - -import logging - -from pyeight.eight import EightSleep - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from . import EightSleepBaseEntity, EightSleepConfigEntryData -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) -BINARY_SENSORS = ["bed_presence"] - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the eight sleep binary sensor.""" - config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] - eight = config_entry_data.api - heat_coordinator = config_entry_data.heat_coordinator - async_add_entities( - EightHeatSensor(entry, heat_coordinator, eight, user.user_id, binary_sensor) - for user in eight.users.values() - for binary_sensor in BINARY_SENSORS - ) - - -class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): - """Representation of a Eight Sleep heat-based sensor.""" - - _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str | None, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, user_id, sensor) - assert self._user_obj - _LOGGER.debug( - "Presence Sensor: %s, Side: %s, User: %s", - sensor, - self._user_obj.side, - user_id, - ) - - @property - def is_on(self) -> bool: - """Return true if the binary sensor is on.""" - assert self._user_obj - return bool(self._user_obj.bed_presence) diff --git a/homeassistant/components/eight_sleep/config_flow.py b/homeassistant/components/eight_sleep/config_flow.py index 504fbeb2817..8839cdf4719 100644 --- a/homeassistant/components/eight_sleep/config_flow.py +++ b/homeassistant/components/eight_sleep/config_flow.py @@ -1,90 +1,11 @@ -"""Config flow for Eight Sleep integration.""" -from __future__ import annotations +"""The Eight Sleep integration config flow.""" -import logging -from typing import Any +from homeassistant.config_entries import ConfigFlow -from pyeight.eight import EightSleep -from pyeight.exceptions import RequestError -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import ( - TextSelector, - TextSelectorConfig, - TextSelectorType, -) - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): TextSelector( - TextSelectorConfig(type=TextSelectorType.EMAIL) - ), - vol.Required(CONF_PASSWORD): TextSelector( - TextSelectorConfig(type=TextSelectorType.PASSWORD) - ), - } -) +from . import DOMAIN -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class EightSleepConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Eight Sleep.""" VERSION = 1 - - async def _validate_data(self, config: dict[str, str]) -> str | None: - """Validate input data and return any error.""" - await self.async_set_unique_id(config[CONF_USERNAME].lower()) - self._abort_if_unique_id_configured() - - eight = EightSleep( - config[CONF_USERNAME], - config[CONF_PASSWORD], - self.hass.config.time_zone, - client_session=async_get_clientsession(self.hass), - ) - - try: - await eight.fetch_token() - except RequestError as err: - return str(err) - - return None - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) - - if (err := await self._validate_data(user_input)) is not None: - return self.async_show_form( - step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": "cannot_connect"}, - description_placeholders={"error": err}, - ) - - return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) - - async def async_step_import(self, import_config: dict) -> FlowResult: - """Handle import.""" - if (err := await self._validate_data(import_config)) is not None: - _LOGGER.error("Unable to import configuration.yaml configuration: %s", err) - return self.async_abort( - reason="cannot_connect", description_placeholders={"error": err} - ) - - return self.async_create_entry( - title=import_config[CONF_USERNAME], data=import_config - ) diff --git a/homeassistant/components/eight_sleep/const.py b/homeassistant/components/eight_sleep/const.py deleted file mode 100644 index 23689066665..00000000000 --- a/homeassistant/components/eight_sleep/const.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Eight Sleep constants.""" -DOMAIN = "eight_sleep" - -HEAT_ENTITY = "heat" -USER_ENTITY = "user" - -NAME_MAP = { - "current_sleep": "Sleep Session", - "current_sleep_fitness": "Sleep Fitness", - "last_sleep": "Previous Sleep Session", -} - -SERVICE_HEAT_SET = "heat_set" - -ATTR_TARGET = "target" -ATTR_DURATION = "duration" diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index 71e01f75d46..a4f7482c920 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -1,10 +1,9 @@ { "domain": "eight_sleep", "name": "Eight Sleep", - "codeowners": ["@mezz64", "@raman325"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/eight_sleep", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["pyeight"], - "requirements": ["pyEight==0.3.2"] + "requirements": [] } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py deleted file mode 100644 index e546318a4dd..00000000000 --- a/homeassistant/components/eight_sleep/sensor.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Support for Eight Sleep sensors.""" -from __future__ import annotations - -import logging -from typing import Any - -from pyeight.eight import EightSleep -import voluptuous as vol - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, - async_get_current_platform, -) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from . import EightSleepBaseEntity, EightSleepConfigEntryData -from .const import ATTR_DURATION, ATTR_TARGET, DOMAIN, SERVICE_HEAT_SET - -ATTR_ROOM_TEMP = "Room Temperature" -ATTR_AVG_ROOM_TEMP = "Average Room Temperature" -ATTR_BED_TEMP = "Bed Temperature" -ATTR_AVG_BED_TEMP = "Average Bed Temperature" -ATTR_RESP_RATE = "Respiratory Rate" -ATTR_AVG_RESP_RATE = "Average Respiratory Rate" -ATTR_HEART_RATE = "Heart Rate" -ATTR_AVG_HEART_RATE = "Average Heart Rate" -ATTR_SLEEP_DUR = "Time Slept" -ATTR_LIGHT_PERC = f"Light Sleep {PERCENTAGE}" -ATTR_DEEP_PERC = f"Deep Sleep {PERCENTAGE}" -ATTR_REM_PERC = f"REM Sleep {PERCENTAGE}" -ATTR_TNT = "Tosses & Turns" -ATTR_SLEEP_STAGE = "Sleep Stage" -ATTR_TARGET_HEAT = "Target Heating Level" -ATTR_ACTIVE_HEAT = "Heating Active" -ATTR_DURATION_HEAT = "Heating Time Remaining" -ATTR_PROCESSING = "Processing" -ATTR_SESSION_START = "Session Start" -ATTR_FIT_DATE = "Fitness Date" -ATTR_FIT_DURATION_SCORE = "Fitness Duration Score" -ATTR_FIT_ASLEEP_SCORE = "Fitness Asleep Score" -ATTR_FIT_OUT_SCORE = "Fitness Out-of-Bed Score" -ATTR_FIT_WAKEUP_SCORE = "Fitness Wakeup Score" - -_LOGGER = logging.getLogger(__name__) - -EIGHT_USER_SENSORS = [ - "current_sleep", - "current_sleep_fitness", - "last_sleep", - "bed_temperature", - "sleep_stage", -] -EIGHT_HEAT_SENSORS = ["bed_state"] -EIGHT_ROOM_SENSORS = ["room_temperature"] - -VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) -VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) - -SERVICE_EIGHT_SCHEMA = { - ATTR_TARGET: VALID_TARGET_HEAT, - ATTR_DURATION: VALID_DURATION, -} - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the eight sleep sensors.""" - config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] - eight = config_entry_data.api - heat_coordinator = config_entry_data.heat_coordinator - user_coordinator = config_entry_data.user_coordinator - - all_sensors: list[SensorEntity] = [] - - for obj in eight.users.values(): - all_sensors.extend( - EightUserSensor(entry, user_coordinator, eight, obj.user_id, sensor) - for sensor in EIGHT_USER_SENSORS - ) - all_sensors.extend( - EightHeatSensor(entry, heat_coordinator, eight, obj.user_id, sensor) - for sensor in EIGHT_HEAT_SENSORS - ) - - all_sensors.extend( - EightRoomSensor(entry, user_coordinator, eight, sensor) - for sensor in EIGHT_ROOM_SENSORS - ) - - async_add_entities(all_sensors) - - platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_HEAT_SET, - SERVICE_EIGHT_SCHEMA, - "async_heat_set", - ) - - -class EightHeatSensor(EightSleepBaseEntity, SensorEntity): - """Representation of an eight sleep heat-based sensor.""" - - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, user_id, sensor) - assert self._user_obj - - _LOGGER.debug( - "Heat Sensor: %s, Side: %s, User: %s", - self._sensor, - self._user_obj.side, - self._user_id, - ) - - @property - def native_value(self) -> int | None: - """Return the state of the sensor.""" - assert self._user_obj - return self._user_obj.heating_level - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device state attributes.""" - assert self._user_obj - return { - ATTR_TARGET_HEAT: self._user_obj.target_heating_level, - ATTR_ACTIVE_HEAT: self._user_obj.now_heating, - ATTR_DURATION_HEAT: self._user_obj.heating_remaining, - } - - -def _get_breakdown_percent( - attr: dict[str, Any], key: str, denominator: int | float -) -> int | float: - """Get a breakdown percent.""" - try: - return round((attr["breakdown"][key] / denominator) * 100, 2) - except (ZeroDivisionError, KeyError): - return 0 - - -def _get_rounded_value(attr: dict[str, Any], key: str) -> int | float | None: - """Get rounded value for given key.""" - if (val := attr.get(key)) is None: - return None - return round(val, 2) - - -class EightUserSensor(EightSleepBaseEntity, SensorEntity): - """Representation of an eight sleep user-based sensor.""" - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, user_id, sensor) - assert self._user_obj - - if self._sensor == "bed_temperature": - self._attr_icon = "mdi:thermometer" - self._attr_device_class = SensorDeviceClass.TEMPERATURE - self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - elif self._sensor in ("current_sleep", "last_sleep", "current_sleep_fitness"): - self._attr_native_unit_of_measurement = "Score" - - if self._sensor != "sleep_stage": - self._attr_state_class = SensorStateClass.MEASUREMENT - - _LOGGER.debug( - "User Sensor: %s, Side: %s, User: %s", - self._sensor, - self._user_obj.side, - self._user_id, - ) - - @property - def native_value(self) -> str | int | float | None: - """Return the state of the sensor.""" - if not self._user_obj: - return None - - if "current" in self._sensor: - if "fitness" in self._sensor: - return self._user_obj.current_sleep_fitness_score - return self._user_obj.current_sleep_score - - if "last" in self._sensor: - return self._user_obj.last_sleep_score - - if self._sensor == "bed_temperature": - return self._user_obj.current_values["bed_temp"] - - if self._sensor == "sleep_stage": - return self._user_obj.current_values["stage"] - - return None - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return device state attributes.""" - attr = None - if "current" in self._sensor and self._user_obj: - if "fitness" in self._sensor: - attr = self._user_obj.current_fitness_values - else: - attr = self._user_obj.current_values - elif "last" in self._sensor and self._user_obj: - attr = self._user_obj.last_values - - if attr is None: - # Skip attributes if sensor type doesn't support - return None - - if "fitness" in self._sensor: - state_attr = { - ATTR_FIT_DATE: attr["date"], - ATTR_FIT_DURATION_SCORE: attr["duration"], - ATTR_FIT_ASLEEP_SCORE: attr["asleep"], - ATTR_FIT_OUT_SCORE: attr["out"], - ATTR_FIT_WAKEUP_SCORE: attr["wakeup"], - } - return state_attr - - state_attr = {ATTR_SESSION_START: attr["date"]} - state_attr[ATTR_TNT] = attr["tnt"] - state_attr[ATTR_PROCESSING] = attr["processing"] - - if attr.get("breakdown") is not None: - sleep_time = sum(attr["breakdown"].values()) - attr["breakdown"]["awake"] - state_attr[ATTR_SLEEP_DUR] = sleep_time - state_attr[ATTR_LIGHT_PERC] = _get_breakdown_percent( - attr, "light", sleep_time - ) - state_attr[ATTR_DEEP_PERC] = _get_breakdown_percent( - attr, "deep", sleep_time - ) - state_attr[ATTR_REM_PERC] = _get_breakdown_percent(attr, "rem", sleep_time) - - room_temp = _get_rounded_value(attr, "room_temp") - bed_temp = _get_rounded_value(attr, "bed_temp") - - if "current" in self._sensor: - state_attr[ATTR_RESP_RATE] = _get_rounded_value(attr, "resp_rate") - state_attr[ATTR_HEART_RATE] = _get_rounded_value(attr, "heart_rate") - state_attr[ATTR_SLEEP_STAGE] = attr["stage"] - state_attr[ATTR_ROOM_TEMP] = room_temp - state_attr[ATTR_BED_TEMP] = bed_temp - elif "last" in self._sensor: - state_attr[ATTR_AVG_RESP_RATE] = _get_rounded_value(attr, "resp_rate") - state_attr[ATTR_AVG_HEART_RATE] = _get_rounded_value(attr, "heart_rate") - state_attr[ATTR_AVG_ROOM_TEMP] = room_temp - state_attr[ATTR_AVG_BED_TEMP] = bed_temp - - return state_attr - - -class EightRoomSensor(EightSleepBaseEntity, SensorEntity): - """Representation of an eight sleep room sensor.""" - - _attr_icon = "mdi:thermometer" - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - - def __init__( - self, - entry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, None, sensor) - - @property - def native_value(self) -> int | float | None: - """Return the state of the sensor.""" - return self._eight.room_temperature diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml deleted file mode 100644 index b191187bb0a..00000000000 --- a/homeassistant/components/eight_sleep/services.yaml +++ /dev/null @@ -1,20 +0,0 @@ -heat_set: - target: - entity: - integration: eight_sleep - domain: sensor - fields: - duration: - required: true - selector: - number: - min: 0 - max: 28800 - unit_of_measurement: seconds - target: - required: true - selector: - number: - min: -100 - max: 100 - unit_of_measurement: "°" diff --git a/homeassistant/components/eight_sleep/strings.json b/homeassistant/components/eight_sleep/strings.json index b2fb73cc020..15773084462 100644 --- a/homeassistant/components/eight_sleep/strings.json +++ b/homeassistant/components/eight_sleep/strings.json @@ -1,35 +1,8 @@ { - "config": { - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:component::eight_sleep::config::error::cannot_connect%]" - } - }, - "services": { - "heat_set": { - "name": "Heat set", - "description": "Sets heating/cooling level for eight sleep.", - "fields": { - "duration": { - "name": "Duration", - "description": "Duration to heat/cool at the target level in seconds." - }, - "target": { - "name": "Target", - "description": "Target cooling/heating level from -100 to 100." - } - } + "issues": { + "integration_removed": { + "title": "The Eight Sleep integration has been removed", + "description": "The Eight Sleep integration has been removed from Home Assistant.\n\nThe Eight Sleep API has changed and now requires a unique secret which is inaccessible outside of their apps.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Eight Sleep integration entries]({entries})." } } } diff --git a/homeassistant/components/elgato/entity.py b/homeassistant/components/elgato/entity.py index 4f4c2a9d8e9..1bbd32f5b44 100644 --- a/homeassistant/components/elgato/entity.py +++ b/homeassistant/components/elgato/entity.py @@ -23,6 +23,7 @@ class ElgatoEntity(CoordinatorEntity[ElgatoDataUpdateCoordinator]): super().__init__(coordinator=coordinator) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.data.info.serial_number)}, + serial_number=coordinator.data.info.serial_number, manufacturer="Elgato", model=coordinator.data.info.product_name, name=coordinator.data.info.display_name, diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 28d8826a066..49340f028d0 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["elgato==4.0.1"], + "requirements": ["elgato==5.0.0"], "zeroconf": ["_elg._tcp.local."] } diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index 379f0bec9d7..069fc3177d6 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -204,7 +204,7 @@ class Config: ): return self.entities[state.entity_id][CONF_ENTITY_NAME] - return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) + return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) # type: ignore[no-any-return] @cache # pylint: disable=method-cache-max-size-none def get_exposed_states(self) -> list[State]: diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 566779671e8..6dfd49c371c 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -121,6 +121,12 @@ DIMMABLE_SUPPORT_FEATURES = ( ) +@lru_cache(maxsize=32) +def _remote_is_allowed(address: str) -> bool: + """Check if remote address is allowed.""" + return is_local(ip_address(address)) + + class HueUnauthorizedUser(HomeAssistantView): """Handle requests to find the emulated hue bridge.""" @@ -145,7 +151,7 @@ class HueUsernameView(HomeAssistantView): async def post(self, request: web.Request) -> web.Response: """Handle a POST request.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) try: @@ -174,7 +180,7 @@ class HueAllGroupsStateView(HomeAssistantView): def get(self, request: web.Request, username: str) -> web.Response: """Process a request to make the Brilliant Lightpad work.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json({}) @@ -195,7 +201,7 @@ class HueGroupView(HomeAssistantView): def put(self, request: web.Request, username: str) -> web.Response: """Process a request to make the Logitech Pop working.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json( @@ -226,7 +232,7 @@ class HueAllLightsStateView(HomeAssistantView): def get(self, request: web.Request, username: str) -> web.Response: """Process a request to get the list of available lights.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json(create_list_of_entities(self.config, request)) @@ -247,7 +253,7 @@ class HueFullStateView(HomeAssistantView): def get(self, request: web.Request, username: str) -> web.Response: """Process a request to get the list of available lights.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) if username != HUE_API_USERNAME: return self.json(UNAUTHORIZED_USER) @@ -276,7 +282,7 @@ class HueConfigView(HomeAssistantView): def get(self, request: web.Request, username: str = "") -> web.Response: """Process a request to get the configuration.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) json_response = create_config_model(self.config, request) @@ -299,7 +305,7 @@ class HueOneLightStateView(HomeAssistantView): def get(self, request: web.Request, username: str, entity_id: str) -> web.Response: """Process a request to get the state of an individual light.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) hass: core.HomeAssistant = request.app["hass"] @@ -341,7 +347,7 @@ class HueOneLightChangeView(HomeAssistantView): ) -> web.Response: """Process a request to set the state of an individual light.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) config = self.config diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json index 9a72541bb50..4a9c1b4aacf 100644 --- a/homeassistant/components/energy/strings.json +++ b/homeassistant/components/energy/strings.json @@ -38,11 +38,11 @@ "description": "The following entities do not have an expected unit of measurement {price_units}:" }, "entity_unexpected_unit_gas_price": { - "title": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::title%]", + "title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]", "description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]" }, "entity_unexpected_unit_water_price": { - "title": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::title%]", + "title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]", "description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]" }, "entity_unexpected_state_class": { diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 7830d3649f2..a4ee4d0d15f 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -274,10 +274,10 @@ async def ws_get_fossil_energy_consumption( statistic_ids, "hour", {"energy": UnitOfEnergy.KILO_WATT_HOUR}, - {"mean", "sum"}, + {"mean", "change"}, ) - def _combine_sum_statistics( + def _combine_change_statistics( stats: dict[str, list[StatisticsRow]], statistic_ids: list[str] ) -> dict[float, float]: """Combine multiple statistics, returns a dict indexed by start time.""" @@ -287,21 +287,12 @@ async def ws_get_fossil_energy_consumption( if statistics_id not in statistic_ids: continue for period in stat: - if period["sum"] is None: + if period["change"] is None: continue - result[period["start"]] += period["sum"] + result[period["start"]] += period["change"] return {key: result[key] for key in sorted(result)} - def _calculate_deltas(sums: dict[float, float]) -> dict[float, float]: - prev: float | None = None - result: dict[float, float] = {} - for period, sum_ in sums.items(): - if prev is not None: - result[period] = sum_ - prev - prev = sum_ - return result - def _reduce_deltas( stat_list: list[dict[str, Any]], same_period: Callable[[float, float], bool], @@ -334,10 +325,9 @@ async def ws_get_fossil_energy_consumption( return result - merged_energy_statistics = _combine_sum_statistics( + merged_energy_statistics = _combine_change_statistics( statistics, msg["energy_statistic_ids"] ) - energy_deltas = _calculate_deltas(merged_energy_statistics) indexed_co2_statistics = cast( dict[float, float], { @@ -349,7 +339,7 @@ async def ws_get_fossil_energy_consumption( # Calculate amount of fossil based energy, assume 100% fossil if missing fossil_energy = [ {"start": start, "delta": delta * indexed_co2_statistics.get(start, 100) / 100} - for start, delta in energy_deltas.items() + for start, delta in merged_energy_statistics.items() ] if msg["period"] == "hour": diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 917e325be51..0700bd4e71a 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.11.4"], + "requirements": ["pyenphase==1.13.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 50d4de18f12..918e4002e7a 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -1,10 +1,13 @@ """Number platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Any -from pyenphase import EnvoyDryContactSettings +from pyenphase import Envoy, EnvoyDryContactSettings +from pyenphase.const import SupportedFeatures +from pyenphase.models.tariff import EnvoyStorageSettings from homeassistant.components.number import ( NumberDeviceClass, @@ -12,7 +15,7 @@ from homeassistant.components.number import ( NumberEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -36,6 +39,21 @@ class EnvoyRelayNumberEntityDescription( """Describes an Envoy Dry Contact Relay number entity.""" +@dataclass +class EnvoyStorageSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyStorageSettings], float] + update_fn: Callable[[Envoy, float], Awaitable[dict[str, Any]]] + + +@dataclass +class EnvoyStorageSettingsNumberEntityDescription( + NumberEntityDescription, EnvoyStorageSettingsRequiredKeysMixin +): + """Describes an Envoy storage mode number entity.""" + + RELAY_ENTITIES = ( EnvoyRelayNumberEntityDescription( key="soc_low", @@ -53,6 +71,15 @@ RELAY_ENTITIES = ( ), ) +STORAGE_RESERVE_SOC_ENTITY = EnvoyStorageSettingsNumberEntityDescription( + key="reserve_soc", + translation_key="reserve_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + value_fn=lambda storage_settings: storage_settings.reserved_soc, + update_fn=lambda envoy, value: envoy.set_reserve_soc(int(value)), +) + async def async_setup_entry( hass: HomeAssistant, @@ -70,6 +97,14 @@ async def async_setup_entry( for entity in RELAY_ENTITIES for relay in envoy_data.dry_contact_settings ) + if ( + envoy_data.tariff + and envoy_data.tariff.storage_settings + and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE + ): + entities.append( + EnvoyStorageSettingsNumberEntity(coordinator, STORAGE_RESERVE_SOC_ENTITY) + ) async_add_entities(entities) @@ -114,3 +149,42 @@ class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity): {"id": self._relay_id, self.entity_description.key: int(value)} ) await self.coordinator.async_request_refresh() + + +class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity): + """Representation of an Enphase storage settings number entity.""" + + entity_description: EnvoyStorageSettingsNumberEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyStorageSettingsNumberEntityDescription, + ) -> None: + """Initialize the Enphase relay number entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + assert self.data.enpower is not None + enpower = self.data.enpower + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def native_value(self) -> float: + """Return the state of the storage setting entity.""" + assert self.data.tariff is not None + assert self.data.tariff.storage_settings is not None + return self.entity_description.value_fn(self.data.tariff.storage_settings) + + async def async_set_native_value(self, value: float) -> None: + """Update the storage setting.""" + await self.entity_description.update_fn(self.envoy, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 5ae73a315f2..331d2a999ad 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -1,12 +1,14 @@ """Select platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from typing import Any from pyenphase import Envoy, EnvoyDryContactSettings +from pyenphase.const import SupportedFeatures from pyenphase.models.dry_contacts import DryContactAction, DryContactMode +from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -36,6 +38,21 @@ class EnvoyRelaySelectEntityDescription( """Describes an Envoy Dry Contact Relay select entity.""" +@dataclass +class EnvoyStorageSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyStorageSettings], str] + update_fn: Callable[[Envoy, str], Awaitable[dict[str, Any]]] + + +@dataclass +class EnvoyStorageSettingsSelectEntityDescription( + SelectEntityDescription, EnvoyStorageSettingsRequiredKeysMixin +): + """Describes an Envoy storage settings select entity.""" + + RELAY_MODE_MAP = { DryContactMode.MANUAL: "standard", DryContactMode.STATE_OF_CHARGE: "battery", @@ -51,6 +68,14 @@ REVERSE_RELAY_ACTION_MAP = {v: k for k, v in RELAY_ACTION_MAP.items()} MODE_OPTIONS = list(REVERSE_RELAY_MODE_MAP) ACTION_OPTIONS = list(REVERSE_RELAY_ACTION_MAP) +STORAGE_MODE_MAP = { + EnvoyStorageMode.BACKUP: "backup", + EnvoyStorageMode.SELF_CONSUMPTION: "self_consumption", + EnvoyStorageMode.SAVINGS: "savings", +} +REVERSE_STORAGE_MODE_MAP = {v: k for k, v in STORAGE_MODE_MAP.items()} +STORAGE_MODE_OPTIONS = list(REVERSE_STORAGE_MODE_MAP) + RELAY_ENTITIES = ( EnvoyRelaySelectEntityDescription( key="mode", @@ -101,6 +126,15 @@ RELAY_ENTITIES = ( ), ), ) +STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription( + key="storage_mode", + translation_key="storage_mode", + options=STORAGE_MODE_OPTIONS, + value_fn=lambda storage_settings: STORAGE_MODE_MAP[storage_settings.mode], + update_fn=lambda envoy, value: envoy.set_storage_mode( + REVERSE_STORAGE_MODE_MAP[value] + ), +) async def async_setup_entry( @@ -119,6 +153,14 @@ async def async_setup_entry( for entity in RELAY_ENTITIES for relay in envoy_data.dry_contact_settings ) + if ( + envoy_data.tariff + and envoy_data.tariff.storage_settings + and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE + ): + entities.append( + EnvoyStorageSettingsSelectEntity(coordinator, STORAGE_MODE_ENTITY) + ) async_add_entities(entities) @@ -164,3 +206,43 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): """Update the relay.""" await self.entity_description.update_fn(self.envoy, self.relay, option) await self.coordinator.async_request_refresh() + + +class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): + """Representation of an Enphase storage settings select entity.""" + + entity_description: EnvoyStorageSettingsSelectEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyStorageSettingsSelectEntityDescription, + ) -> None: + """Initialize the Enphase storage settings select entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + assert coordinator.envoy.data is not None + assert coordinator.envoy.data.enpower is not None + enpower = coordinator.envoy.data.enpower + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def current_option(self) -> str: + """Return the state of the select entity.""" + assert self.data.tariff is not None + assert self.data.tariff.storage_settings is not None + return self.entity_description.value_fn(self.data.tariff.storage_settings) + + async def async_select_option(self, option: str) -> None: + """Update the relay.""" + await self.entity_description.update_fn(self.envoy, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 92eca38ef20..94cf9233745 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -39,6 +39,9 @@ }, "restore_battery_level": { "name": "Restore battery level" + }, + "reserve_soc": { + "name": "Reserve battery level" } }, "select": { @@ -75,6 +78,14 @@ "schedule": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::schedule%]", "none": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::none%]" } + }, + "storage_mode": { + "name": "Storage mode", + "state": { + "self_consumption": "Self consumption", + "backup": "Full backup", + "savings": "Savings mode" + } } }, "sensor": { @@ -122,6 +133,9 @@ } }, "switch": { + "charge_from_grid": { + "name": "Charge from grid" + }, "grid_enabled": { "name": "Grid enabled" } diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index fb9e14406ac..22746fd9479 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -1,13 +1,15 @@ """Switch platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass import logging from typing import Any from pyenphase import Envoy, EnvoyDryContactStatus, EnvoyEnpower +from pyenphase.const import SupportedFeatures from pyenphase.models.dry_contacts import DryContactStatus +from pyenphase.models.tariff import EnvoyStorageSettings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -54,6 +56,22 @@ class EnvoyDryContactSwitchEntityDescription( """Describes an Envoy Enpower dry contact switch entity.""" +@dataclass +class EnvoyStorageSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyStorageSettings], bool] + turn_on_fn: Callable[[Envoy], Awaitable[dict[str, Any]]] + turn_off_fn: Callable[[Envoy], Awaitable[dict[str, Any]]] + + +@dataclass +class EnvoyStorageSettingsSwitchEntityDescription( + SwitchEntityDescription, EnvoyStorageSettingsRequiredKeysMixin +): + """Describes an Envoy storage settings switch entity.""" + + ENPOWER_GRID_SWITCH = EnvoyEnpowerSwitchEntityDescription( key="mains_admin_state", translation_key="grid_enabled", @@ -69,6 +87,14 @@ RELAY_STATE_SWITCH = EnvoyDryContactSwitchEntityDescription( turn_off_fn=lambda envoy, id: envoy.open_dry_contact(id), ) +CHARGE_FROM_GRID_SWITCH = EnvoyStorageSettingsSwitchEntityDescription( + key="charge_from_grid", + translation_key="charge_from_grid", + value_fn=lambda storage_settings: storage_settings.charge_from_grid, + turn_on_fn=lambda envoy: envoy.enable_charge_from_grid(), + turn_off_fn=lambda envoy: envoy.disable_charge_from_grid(), +) + async def async_setup_entry( hass: HomeAssistant, @@ -95,6 +121,18 @@ async def async_setup_entry( for relay in envoy_data.dry_contact_status ) + if ( + envoy_data.enpower + and envoy_data.tariff + and envoy_data.tariff.storage_settings + and (coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE) + ): + entities.append( + EnvoyStorageSettingsSwitchEntity( + coordinator, CHARGE_FROM_GRID_SWITCH, envoy_data.enpower + ) + ) + async_add_entities(entities) @@ -188,3 +226,47 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): """Turn off (open) the dry contact.""" if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): self.async_write_ha_state() + + +class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): + """Representation of an Enphase storage settings switch entity.""" + + entity_description: EnvoyStorageSettingsSwitchEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyStorageSettingsSwitchEntityDescription, + enpower: EnvoyEnpower, + ) -> None: + """Initialize the Enphase storage settings switch entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + self.enpower = enpower + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def is_on(self) -> bool: + """Return the state of the storage settings switch.""" + assert self.data.tariff is not None + assert self.data.tariff.storage_settings is not None + return self.entity_description.value_fn(self.data.tariff.storage_settings) + + async def async_turn_on(self): + """Turn on the storage settings switch.""" + await self.entity_description.turn_on_fn(self.envoy) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self): + """Turn off the storage switch.""" + await self.entity_description.turn_off_fn(self.envoy) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index db300ab1b28..dc5a4ff0968 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -4,12 +4,13 @@ from __future__ import annotations from collections.abc import Callable import functools import math -from typing import Any, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast from aioesphomeapi import ( EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, + build_unique_id, ) import voluptuous as vol @@ -215,9 +216,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): This method can be overridden in child classes to know when the static info changes. """ - static_info = cast(_InfoT, static_info) + device_info = self._entry_data.device_info + if TYPE_CHECKING: + static_info = cast(_InfoT, static_info) + assert device_info self._static_info = static_info - self._attr_unique_id = static_info.unique_id + self._attr_unique_id = build_unique_id(device_info.mac_address, static_info) self._attr_entity_registry_enabled_default = not static_info.disabled_by_default self._attr_name = static_info.name if entity_category := static_info.entity_category: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index ad9403e3601..e53200c2e90 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -29,18 +29,22 @@ from aioesphomeapi import ( SensorInfo, SensorState, SwitchInfo, + TextInfo, TextSensorInfo, UserService, + build_unique_id, ) from aioesphomeapi.model import ButtonInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store from .bluetooth.device import ESPHomeBluetoothDevice +from .const import DOMAIN from .dashboard import async_get_dashboard INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()} @@ -65,6 +69,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { SelectInfo: Platform.SELECT, SensorInfo: Platform.SENSOR, SwitchInfo: Platform.SWITCH, + TextInfo: Platform.TEXT, TextSensorInfo: Platform.SENSOR, } @@ -244,24 +249,34 @@ class RuntimeEntryData: self.loaded_platforms |= needed async def async_update_static_infos( - self, hass: HomeAssistant, entry: ConfigEntry, infos: list[EntityInfo] + self, hass: HomeAssistant, entry: ConfigEntry, infos: list[EntityInfo], mac: str ) -> None: """Distribute an update of static infos to all platforms.""" # First, load all platforms needed_platforms = set() - if async_get_dashboard(hass): needed_platforms.add(Platform.UPDATE) - if self.device_info is not None and self.device_info.voice_assistant_version: + if self.device_info and self.device_info.voice_assistant_version: needed_platforms.add(Platform.BINARY_SENSOR) needed_platforms.add(Platform.SELECT) + ent_reg = er.async_get(hass) + registry_get_entity = ent_reg.async_get_entity_id for info in infos: - for info_type, platform in INFO_TYPE_TO_PLATFORM.items(): - if isinstance(info, info_type): - needed_platforms.add(platform) - break + platform = INFO_TYPE_TO_PLATFORM[type(info)] + needed_platforms.add(platform) + # If the unique id is in the old format, migrate it + # except if they downgraded and upgraded, there might be a duplicate + # so we want to keep the one that was already there. + if ( + (old_unique_id := info.unique_id) + and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id)) + and (new_unique_id := build_unique_id(mac, info)) != old_unique_id + and not registry_get_entity(platform, DOMAIN, new_unique_id) + ): + ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id) + await self._ensure_platforms_loaded(hass, entry, needed_platforms) # Make a dict of the EntityInfo by type and send diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 211404431c0..812cf430d09 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -450,7 +450,9 @@ class ESPHomeManager: try: entity_infos, services = await cli.list_entities_services() - await entry_data.async_update_static_infos(hass, entry, entity_infos) + await entry_data.async_update_static_infos( + hass, entry, entity_infos, device_info.mac_address + ) await _setup_services(hass, entry_data, services) await cli.subscribe_states(entry_data.async_update_state) await cli.subscribe_service_calls(self.async_on_service_call) @@ -544,7 +546,10 @@ class ESPHomeManager: self.reconnect_logic = reconnect_logic infos, services = await entry_data.async_load_from_store() - await entry_data.async_update_static_infos(hass, entry, infos) + if entry.unique_id: + await entry_data.async_update_static_infos( + hass, entry, infos, entry.unique_id.upper() + ) await _setup_services(hass, entry_data, services) if entry_data.device_info is not None and entry_data.device_info.name: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8db47d83cad..702f75b166e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.7", + "aioesphomeapi==18.1.0", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py new file mode 100644 index 00000000000..49049eecfd4 --- /dev/null +++ b/homeassistant/components/esphome/text.py @@ -0,0 +1,63 @@ +"""Support for esphome texts.""" +from __future__ import annotations + +from aioesphomeapi import EntityInfo, TextInfo, TextMode as EsphomeTextMode, TextState + +from homeassistant.components.text import TextEntity, TextMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up esphome texts based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + info_type=TextInfo, + entity_type=EsphomeText, + state_type=TextState, + ) + + +TEXT_MODES: EsphomeEnumMapper[EsphomeTextMode, TextMode] = EsphomeEnumMapper( + { + EsphomeTextMode.TEXT: TextMode.TEXT, + EsphomeTextMode.PASSWORD: TextMode.PASSWORD, + } +) + + +class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity): + """A text implementation for esphome.""" + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_native_min = static_info.min_length + self._attr_native_max = static_info.max_length + self._attr_pattern = static_info.pattern + self._attr_mode = TEXT_MODES.from_esphome(static_info.mode) or TextMode.TEXT + + @property + @esphome_state_property + def native_value(self) -> str | None: + """Return the state of the entity.""" + state = self._state + if state.missing_state: + return None + return state.state + + async def async_set_value(self, value: str) -> None: + """Update the current value.""" + await self._client.text_command(self._key, value) diff --git a/homeassistant/components/eufylife_ble/strings.json b/homeassistant/components/eufylife_ble/strings.json index 5f7924f4cbd..aaeeeb85f67 100644 --- a/homeassistant/components/eufylife_ble/strings.json +++ b/homeassistant/components/eufylife_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 2aa0cd42fe1..4b79ef3df1b 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -487,6 +487,18 @@ class EvoBroker: ) self.temps = None # these are now stale, will fall back to v2 temps + except KeyError as err: + _LOGGER.warning( + ( + "Unable to obtain high-precision temperatures. " + "It appears the JSON schema is not as expected, " + "so the high-precision feature will be disabled until next restart." + "Message is: %s" + ), + err, + ) + self.client_v1 = self.temps = None + else: if ( str(self.client_v1.location_id) @@ -495,7 +507,7 @@ class EvoBroker: _LOGGER.warning( "The v2 API's configured location doesn't match " "the v1 API's default location (there is more than one location), " - "so the high-precision feature will be disabled" + "so the high-precision feature will be disabled until next restart" ) self.client_v1 = self.temps = None else: diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index 55bd862349b..fc7f1ddce1f 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -3,14 +3,24 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import CONF_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) +_ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) async def async_get_actions( diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json index b1e03681c96..705eada9387 100644 --- a/homeassistant/components/fastdotcom/strings.json +++ b/homeassistant/components/fastdotcom/strings.json @@ -2,7 +2,7 @@ "services": { "speedtest": { "name": "Speed test", - "description": "Immediately executs a speed test with Fast.com." + "description": "Immediately executes a speed test with Fast.com." } } } diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 0af6cf02586..cdfa7f6a864 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -2,13 +2,15 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging from typing import Any from pyfibaro.fibaro_client import FibaroClient from pyfibaro.fibaro_device import DeviceModel +from pyfibaro.fibaro_room import RoomModel from pyfibaro.fibaro_scene import SceneModel +from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry @@ -45,6 +47,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.LOCK, Platform.SWITCH, + Platform.EVENT, ] FIBARO_TYPEMAP = { @@ -86,28 +89,32 @@ class FibaroController: # Whether to import devices from plugins self._import_plugins = config[CONF_IMPORT_PLUGINS] - self._room_map = None # Mapping roomId to room object - self._device_map = None # Mapping deviceId to device object + self._room_map: dict[int, RoomModel] # Mapping roomId to room object + self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list ) # List of devices by entity platform # All scenes self._scenes: list[SceneModel] = [] - self._callbacks: dict[Any, Any] = {} # Update value callbacks by deviceId + self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId + # Event callbacks by device id + self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} self.hub_serial: str # Unique serial number of the hub self.hub_name: str # The friendly name of the hub + self.hub_model: str self.hub_software_version: str self.hub_api_url: str = config[CONF_URL] # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} - def connect(self): + def connect(self) -> bool: """Start the communication with the Fibaro controller.""" connected = self._client.connect() info = self._client.read_info() self.hub_serial = info.serial_number self.hub_name = info.hc_name + self.hub_model = info.platform self.hub_software_version = info.current_version if connected is False: @@ -138,15 +145,15 @@ class FibaroController: except Exception as ex: raise FibaroConnectFailed from ex - def enable_state_handler(self): + def enable_state_handler(self) -> None: """Start StateHandler thread for monitoring updates.""" self._client.register_update_handler(self._on_state_change) - def disable_state_handler(self): + def disable_state_handler(self) -> None: """Stop StateHandler thread used for monitoring updates.""" self._client.unregister_update_handler() - def _on_state_change(self, state): + def _on_state_change(self, state: Any) -> None: """Handle change report received from the HomeCenter.""" callback_set = set() for change in state.get("changes", []): @@ -177,12 +184,32 @@ class FibaroController: for callback in self._callbacks[item]: callback() - def register(self, device_id, callback): - """Register device with a callback for updates.""" - self._callbacks.setdefault(device_id, []) - self._callbacks[device_id].append(callback) + resolver = FibaroStateResolver(state) + for event in resolver.get_events(): + fibaro_id = event.fibaro_id + if ( + event.event_type.lower() == "centralsceneevent" + and fibaro_id in self._event_callbacks + ): + for callback in self._event_callbacks[fibaro_id]: + callback(event) - def get_children(self, device_id): + def register(self, device_id: int, callback: Any) -> None: + """Register device with a callback for updates.""" + device_callbacks = self._callbacks.setdefault(device_id, []) + device_callbacks.append(callback) + + def register_event( + self, device_id: int, callback: Callable[[FibaroEvent], None] + ) -> None: + """Register device with a callback for central scene events. + + The callback receives one parameter with the event. + """ + device_callbacks = self._event_callbacks.setdefault(device_id, []) + device_callbacks.append(callback) + + def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" return [ device @@ -190,7 +217,7 @@ class FibaroController: if device.parent_fibaro_id == device_id ] - def get_children2(self, device_id, endpoint_id): + def get_children2(self, device_id: int, endpoint_id: int) -> list[DeviceModel]: """Get a list of child devices for the same endpoint.""" return [ device @@ -199,17 +226,14 @@ class FibaroController: and (not device.has_endpoint_id or device.endpoint_id == endpoint_id) ] - def get_siblings(self, device): + def get_siblings(self, device: DeviceModel) -> list[DeviceModel]: """Get the siblings of a device.""" if device.has_endpoint_id: - return self.get_children2( - self._device_map[device.fibaro_id].parent_fibaro_id, - self._device_map[device.fibaro_id].endpoint_id, - ) - return self.get_children(self._device_map[device.fibaro_id].parent_fibaro_id) + return self.get_children2(device.parent_fibaro_id, device.endpoint_id) + return self.get_children(device.parent_fibaro_id) @staticmethod - def _map_device_to_platform(device: Any) -> Platform | None: + def _map_device_to_platform(device: DeviceModel) -> Platform | None: """Map device to HA device type.""" # Use our lookup table to identify device type platform: Platform | None = None @@ -228,6 +252,8 @@ class FibaroController: platform = Platform.COVER elif "secure" in device.actions: platform = Platform.LOCK + elif device.has_central_scene_event: + platform = Platform.EVENT elif device.value.has_value: if device.value.is_bool_value: platform = Platform.BINARY_SENSOR @@ -248,7 +274,7 @@ class FibaroController: if device.parent_fibaro_id <= 1: return - master_entity: Any | None = None + master_entity: DeviceModel | None = None if device.parent_fibaro_id == 1: master_entity = device else: @@ -271,7 +297,7 @@ class FibaroController: via_device=(DOMAIN, self.hub_serial), ) - def get_device_info(self, device: Any) -> DeviceInfo: + def get_device_info(self, device: DeviceModel) -> DeviceInfo: """Get the device info by fibaro device id.""" if device.fibaro_id in self._device_infos: return self._device_infos[device.fibaro_id] @@ -289,7 +315,7 @@ class FibaroController: """Return list of scenes.""" return self._scenes - def _read_devices(self): + def _read_devices(self) -> None: """Read and process the device list.""" devices = self._client.read_devices() self._device_map = {} @@ -382,9 +408,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, controller.hub_serial)}, + serial_number=controller.hub_serial, manufacturer="Fibaro", name=controller.hub_name, - model=controller.hub_serial, + model=controller.hub_model, sw_version=controller.hub_software_version, configuration_url=controller.hub_api_url.removesuffix("/api/"), ) diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py new file mode 100644 index 00000000000..020a478db95 --- /dev/null +++ b/homeassistant/components/fibaro/event.py @@ -0,0 +1,68 @@ +"""Support for Fibaro event entities.""" +from __future__ import annotations + +from pyfibaro.fibaro_device import DeviceModel, SceneEvent +from pyfibaro.fibaro_state_resolver import FibaroEvent + +from homeassistant.components.event import ( + ENTITY_ID_FORMAT, + EventDeviceClass, + EventEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FibaroController, FibaroDevice +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Fibaro event entities.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + + entities = [] + for device in controller.fibaro_devices[Platform.EVENT]: + for scene_event in device.central_scene_event: + # Each scene event represents a button on a device + entities.append(FibaroEventEntity(device, scene_event)) + + async_add_entities(entities, True) + + +class FibaroEventEntity(FibaroDevice, EventEntity): + """Representation of a Fibaro Event Entity.""" + + def __init__(self, fibaro_device: DeviceModel, scene_event: SceneEvent) -> None: + """Initialize the Fibaro device.""" + super().__init__(fibaro_device) + + key_id = scene_event.key_id + + self.entity_id = ENTITY_ID_FORMAT.format(f"{self.ha_id}_button_{key_id}") + + self._button = key_id + + self._attr_name = f"{fibaro_device.friendly_name} Button {key_id}" + self._attr_device_class = EventDeviceClass.BUTTON + self._attr_event_types = scene_event.key_event_types + self._attr_unique_id = f"{fibaro_device.unique_id_str}.{key_id}" + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + + # Register event callback + self.controller.register_event( + self.fibaro_device.fibaro_id, self._event_callback + ) + + def _event_callback(self, event: FibaroEvent) -> None: + if event.key_id == self._button: + self._trigger_event(event.key_event_type) + self.schedule_update_ha_state() diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index d90a9d28662..68763228f82 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.7.2"] + "requirements": ["pyfibaro==0.7.6"] } diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 04946f6386f..40ea9fb1152 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -1 +1,59 @@ """The fitbit component.""" + + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN, FitbitScope +from .coordinator import FitbitData, FitbitDeviceCoordinator +from .exceptions import FitbitApiException, FitbitAuthException +from .model import config_from_entry_data + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up fitbit from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + fitbit_api = api.OAuthFitbitApi( + hass, session, unit_system=entry.data.get("unit_system") + ) + try: + await fitbit_api.async_get_access_token() + except FitbitAuthException as err: + raise ConfigEntryAuthFailed from err + except FitbitApiException as err: + raise ConfigEntryNotReady from err + + fitbit_config = config_from_entry_data(entry.data) + coordinator: FitbitDeviceCoordinator | None = None + if fitbit_config.is_allowed_resource(FitbitScope.DEVICE, "devices/battery"): + coordinator = FitbitDeviceCoordinator(hass, fitbit_api) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = FitbitData( + api=fitbit_api, device_coordinator=coordinator + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index bf287471292..ceb619c4385 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -1,45 +1,70 @@ """API for fitbit bound to Home Assistant OAuth.""" +from abc import ABC, abstractmethod +from collections.abc import Callable import logging -from typing import Any, cast +from typing import Any, TypeVar, cast from fitbit import Fitbit +from fitbit.exceptions import HTTPException, HTTPUnauthorized +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.unit_system import METRIC_SYSTEM from .const import FitbitUnitSystem +from .exceptions import FitbitApiException, FitbitAuthException from .model import FitbitDevice, FitbitProfile _LOGGER = logging.getLogger(__name__) +CONF_REFRESH_TOKEN = "refresh_token" +CONF_EXPIRES_AT = "expires_at" -class FitbitApi: - """Fitbit client library wrapper base class.""" + +_T = TypeVar("_T") + + +class FitbitApi(ABC): + """Fitbit client library wrapper base class. + + This can be subclassed with different implementations for providing an access + token depending on the use case. + """ def __init__( self, hass: HomeAssistant, - client: Fitbit, unit_system: FitbitUnitSystem | None = None, ) -> None: """Initialize Fitbit auth.""" self._hass = hass self._profile: FitbitProfile | None = None - self._client = client self._unit_system = unit_system - @property - def client(self) -> Fitbit: - """Property to expose the underlying client library.""" - return self._client + @abstractmethod + async def async_get_access_token(self) -> dict[str, Any]: + """Return a valid token dictionary for the Fitbit API.""" + + async def _async_get_client(self) -> Fitbit: + """Get synchronous client library, called before each client request.""" + # Always rely on Home Assistant's token update mechanism which refreshes + # the data in the configuration entry. + token = await self.async_get_access_token() + return Fitbit( + client_id=None, + client_secret=None, + access_token=token[CONF_ACCESS_TOKEN], + refresh_token=token[CONF_REFRESH_TOKEN], + expires_at=float(token[CONF_EXPIRES_AT]), + ) async def async_get_user_profile(self) -> FitbitProfile: """Return the user profile from the API.""" if self._profile is None: - response: dict[str, Any] = await self._hass.async_add_executor_job( - self._client.user_profile_get - ) + client = await self._async_get_client() + response: dict[str, Any] = await self._run(client.user_profile_get) _LOGGER.debug("user_profile_get=%s", response) profile = response["user"] self._profile = FitbitProfile( @@ -73,9 +98,8 @@ class FitbitApi: async def async_get_devices(self) -> list[FitbitDevice]: """Return available devices.""" - devices: list[dict[str, str]] = await self._hass.async_add_executor_job( - self._client.get_devices - ) + client = await self._async_get_client() + devices: list[dict[str, str]] = await self._run(client.get_devices) _LOGGER.debug("get_devices=%s", devices) return [ FitbitDevice( @@ -90,17 +114,67 @@ class FitbitApi: async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]: """Return the most recent value from the time series for the specified resource type.""" + client = await self._async_get_client() # Set request header based on the configured unit system - self._client.system = await self.async_get_unit_system() + client.system = await self.async_get_unit_system() def _time_series() -> dict[str, Any]: - return cast( - dict[str, Any], self._client.time_series(resource_type, period="7d") - ) + return cast(dict[str, Any], client.time_series(resource_type, period="7d")) - response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series) + response: dict[str, Any] = await self._run(_time_series) _LOGGER.debug("time_series(%s)=%s", resource_type, response) key = resource_type.replace("/", "-") dated_results: list[dict[str, Any]] = response[key] return dated_results[-1] + + async def _run(self, func: Callable[[], _T]) -> _T: + """Run client command.""" + try: + return await self._hass.async_add_executor_job(func) + except HTTPUnauthorized as err: + _LOGGER.debug("Unauthorized error from fitbit API: %s", err) + raise FitbitAuthException("Authentication error from fitbit API") from err + except HTTPException as err: + _LOGGER.debug("Error from fitbit API: %s", err) + raise FitbitApiException("Error from fitbit API") from err + + +class OAuthFitbitApi(FitbitApi): + """Provide fitbit authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + unit_system: FitbitUnitSystem | None = None, + ) -> None: + """Initialize OAuthFitbitApi.""" + super().__init__(hass, unit_system) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> dict[str, Any]: + """Return a valid access token for the Fitbit API.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token + + +class ConfigFlowFitbitApi(FitbitApi): + """Profile fitbit authentication before a ConfigEntry exists. + + This implementation directly provides the token without supporting refresh. + """ + + def __init__( + self, + hass: HomeAssistant, + token: dict[str, Any], + ) -> None: + """Initialize ConfigFlowFitbitApi.""" + super().__init__(hass) + self._token = token + + async def async_get_access_token(self) -> dict[str, Any]: + """Return the token for the Fitbit API.""" + return self._token diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py new file mode 100644 index 00000000000..caf0384eca2 --- /dev/null +++ b/homeassistant/components/fitbit/application_credentials.py @@ -0,0 +1,95 @@ +"""application_credentials platform the fitbit integration. + +See https://dev.fitbit.com/build/reference/web-api/authorization/ for additional +details on Fitbit authorization. +""" + +import base64 +from http import HTTPStatus +import logging +from typing import Any, cast + +import aiohttp + +from homeassistant.components.application_credentials import ( + AuthImplementation, + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from .exceptions import FitbitApiException, FitbitAuthException + +_LOGGER = logging.getLogger(__name__) + + +class FitbitOAuth2Implementation(AuthImplementation): + """Local OAuth2 implementation for Fitbit. + + This implementation is needed to send the client id and secret as a Basic + Authorization header. + """ + + async def async_resolve_external_data(self, external_data: dict[str, Any]) -> dict: + """Resolve the authorization code to tokens.""" + return await self._post( + { + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + ) + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + return await self._post( + { + **data, + CONF_CLIENT_ID: self.client_id, + CONF_CLIENT_SECRET: self.client_secret, + } + ) + + async def _post(self, data: dict[str, Any]) -> dict[str, Any]: + session = async_get_clientsession(self.hass) + try: + resp = await session.post(self.token_url, data=data, headers=self._headers) + resp.raise_for_status() + except aiohttp.ClientResponseError as err: + if _LOGGER.isEnabledFor(logging.DEBUG): + error_body = await resp.text() if not session.closed else "" + _LOGGER.debug( + "Client response error status=%s, body=%s", err.status, error_body + ) + if err.status == HTTPStatus.UNAUTHORIZED: + raise FitbitAuthException(f"Unauthorized error: {err}") from err + raise FitbitApiException(f"Server error response: {err}") from err + except aiohttp.ClientError as err: + raise FitbitApiException(f"Client connection error: {err}") from err + return cast(dict, await resp.json()) + + @property + def _headers(self) -> dict[str, str]: + """Build necessary authorization headers.""" + basic_auth = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode() + ).decode() + return {"Authorization": f"Basic {basic_auth}"} + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: + """Return a custom auth implementation.""" + return FitbitOAuth2Implementation( + hass, + auth_domain, + credential, + AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ), + ) diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py new file mode 100644 index 00000000000..dd7e79e2c65 --- /dev/null +++ b/homeassistant/components/fitbit/config_flow.py @@ -0,0 +1,97 @@ +"""Config flow for fitbit.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN, OAUTH_SCOPES +from .exceptions import FitbitApiException, FitbitAuthException + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle fitbit OAuth2 authentication.""" + + DOMAIN = DOMAIN + + reauth_entry: ConfigEntry | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, str]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH_SCOPES), + "prompt": "consent" if not self.reauth_entry else "none", + } + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_step_creation( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Create config entry from external data with Fitbit specific error handling.""" + try: + return await super().async_step_creation() + except FitbitAuthException as err: + _LOGGER.error( + "Failed to authenticate when creating Fitbit credentials: %s", err + ) + return self.async_abort(reason="invalid_auth") + except FitbitApiException as err: + _LOGGER.error("Failed to create Fitbit credentials: %s", err) + return self.async_abort(reason="cannot_connect") + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + + client = api.ConfigFlowFitbitApi(self.hass, data[CONF_TOKEN]) + try: + profile = await client.async_get_user_profile() + except FitbitAuthException as err: + _LOGGER.error("Failed to authenticate with Fitbit API: %s", err) + return self.async_abort(reason="invalid_access_token") + except FitbitApiException as err: + _LOGGER.error("Failed to fetch user profile for Fitbit API: %s", err) + return self.async_abort(reason="cannot_connect") + + if self.reauth_entry: + if self.reauth_entry.unique_id != profile.encoded_id: + return self.async_abort(reason="wrong_account") + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + await self.async_set_unique_id(profile.encoded_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=profile.full_name, data=data) + + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + """Handle import from YAML.""" + return await self.async_oauth_create_entry(data) diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 19734add07a..45b81b3919e 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -65,3 +65,23 @@ class FitbitUnitSystem(StrEnum): EN_GB = "en_GB" """Use United Kingdom units.""" + + +CONF_SCOPE: Final = "scope" + + +class FitbitScope(StrEnum): + """OAuth scopes for fitbit.""" + + ACTIVITY = "activity" + HEART_RATE = "heartrate" + NUTRITION = "nutrition" + PROFILE = "profile" + DEVICE = "settings" + SLEEP = "sleep" + WEIGHT = "weight" + + +OAUTH2_AUTHORIZE = "https://www.fitbit.com/oauth2/authorize" +OAUTH2_TOKEN = "https://api.fitbit.com/oauth2/token" +OAUTH_SCOPES = [scope.value for scope in FitbitScope] diff --git a/homeassistant/components/fitbit/coordinator.py b/homeassistant/components/fitbit/coordinator.py new file mode 100644 index 00000000000..5c156955f90 --- /dev/null +++ b/homeassistant/components/fitbit/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for fetching data from fitbit API.""" + +import asyncio +from dataclasses import dataclass +import datetime +import logging +from typing import Final + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import FitbitApi +from .exceptions import FitbitApiException, FitbitAuthException +from .model import FitbitDevice + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) +TIMEOUT = 10 + + +class FitbitDeviceCoordinator(DataUpdateCoordinator): + """Coordinator for fetching fitbit devices from the API.""" + + def __init__(self, hass: HomeAssistant, api: FitbitApi) -> None: + """Initialize FitbitDeviceCoordinator.""" + super().__init__(hass, _LOGGER, name="Fitbit", update_interval=UPDATE_INTERVAL) + self._api = api + + async def _async_update_data(self) -> dict[str, FitbitDevice]: + """Fetch data from API endpoint.""" + async with asyncio.timeout(TIMEOUT): + try: + devices = await self._api.async_get_devices() + except FitbitAuthException as err: + raise ConfigEntryAuthFailed(err) from err + except FitbitApiException as err: + raise UpdateFailed(err) from err + return {device.id: device for device in devices} + + +@dataclass +class FitbitData: + """Config Entry global data.""" + + api: FitbitApi + device_coordinator: FitbitDeviceCoordinator | None diff --git a/homeassistant/components/fitbit/exceptions.py b/homeassistant/components/fitbit/exceptions.py new file mode 100644 index 00000000000..82ac53d5f99 --- /dev/null +++ b/homeassistant/components/fitbit/exceptions.py @@ -0,0 +1,14 @@ +"""Exceptions for fitbit API calls. + +These exceptions exist to provide common exceptions for the async and sync client libraries. +""" + +from homeassistant.exceptions import HomeAssistantError + + +class FitbitApiException(HomeAssistantError): + """Error talking to the fitbit API.""" + + +class FitbitAuthException(FitbitApiException): + """Authentication related error talking to the fitbit API.""" diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index 510fe8da900..7739c7237f0 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -2,7 +2,8 @@ "domain": "fitbit", "name": "Fitbit", "codeowners": ["@allenporter"], - "dependencies": ["configurator", "http"], + "config_flow": true, + "dependencies": ["application_credentials", "http"], "documentation": "https://www.home-assistant.io/integrations/fitbit", "iot_class": "cloud_polling", "loggers": ["fitbit"], diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py index 3d321d8dd01..38b1d0bb786 100644 --- a/homeassistant/components/fitbit/model.py +++ b/homeassistant/components/fitbit/model.py @@ -1,6 +1,10 @@ """Data representation for fitbit API responses.""" +from collections.abc import Mapping from dataclasses import dataclass +from typing import Any + +from .const import CONF_CLOCK_FORMAT, CONF_MONITORED_RESOURCES, FitbitScope @dataclass @@ -35,3 +39,40 @@ class FitbitDevice: type: str """The type of the device such as TRACKER or SCALE.""" + + +@dataclass +class FitbitConfig: + """Information from the fitbit ConfigEntry data.""" + + clock_format: str | None + monitored_resources: set[str] | None + scopes: set[FitbitScope] + + def is_explicit_enable(self, key: str) -> bool: + """Determine if entity is enabled by default.""" + if self.monitored_resources is not None: + return key in self.monitored_resources + return False + + def is_allowed_resource(self, scope: FitbitScope | None, key: str) -> bool: + """Determine if an entity is allowed to be created.""" + if self.is_explicit_enable(key): + return True + return scope in self.scopes + + +def config_from_entry_data(data: Mapping[str, Any]) -> FitbitConfig: + """Parse the integration config entry into a FitbitConfig.""" + + clock_format = data.get(CONF_CLOCK_FORMAT) + + # Originally entities were configured explicitly from yaml config. Newer + # configurations will infer which entities to enable based on the allowed + # scopes the user selected during OAuth. When creating entities based on + # scopes, some entities are disabled by default. + monitored_resources = data.get(CONF_MONITORED_RESOURCES) + fitbit_scopes: set[FitbitScope] = set({}) + if scopes := data["token"].get("scope"): + fitbit_scopes = set({FitbitScope(scope) for scope in scopes.split(" ")}) + return FitbitConfig(clock_format, monitored_resources, fitbit_scopes) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index e08f56e0e34..d0d939ce67e 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,23 +1,21 @@ """Support for the Fitbit API.""" from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass import datetime import logging import os -import time from typing import Any, Final, cast -from aiohttp.web import Request from fitbit import Fitbit -from fitbit.api import FitbitOauth2Client -from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError +from oauthlib.oauth2.rfc6749.errors import OAuth2Error import voluptuous as vol -from homeassistant.components import configurator -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, SensorDeviceClass, @@ -25,22 +23,27 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, + CONF_TOKEN, CONF_UNIT_SYSTEM, PERCENTAGE, + EntityCategory, UnitOfLength, UnitOfMass, UnitOfTime, + UnitOfVolume, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.json import save_json -from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.json import load_json_object from .api import FitbitApi @@ -54,13 +57,15 @@ from .const import ( CONF_MONITORED_RESOURCES, DEFAULT_CLOCK_FORMAT, DEFAULT_CONFIG, - FITBIT_AUTH_CALLBACK_PATH, - FITBIT_AUTH_START, + DOMAIN, FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, + FitbitScope, FitbitUnitSystem, ) -from .model import FitbitDevice +from .coordinator import FitbitData, FitbitDeviceCoordinator +from .exceptions import FitbitApiException, FitbitAuthException +from .model import FitbitDevice, config_from_entry_data _LOGGER: Final = logging.getLogger(__name__) @@ -122,6 +127,13 @@ def _elevation_unit(unit_system: FitbitUnitSystem) -> UnitOfLength: return UnitOfLength.METERS +def _water_unit(unit_system: FitbitUnitSystem) -> UnitOfVolume: + """Determine the water unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfVolume.FLUID_OUNCES + return UnitOfVolume.MILLILITERS + + @dataclass class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" @@ -129,6 +141,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): unit_type: str | None = None value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None + scope: FitbitScope | None = None FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( @@ -137,18 +150,27 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/calories", name="Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( key="activities/caloriesBMR", name="Calories BMR", native_unit_of_measurement="cal", icon="mdi:fire", + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/distance", @@ -157,6 +179,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( key="activities/elevation", @@ -164,12 +188,18 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/floors", name="Floors", native_unit_of_measurement="floors", icon="mdi:walk", + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/heart", @@ -177,6 +207,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="bpm", icon="mdi:heart-pulse", value_fn=lambda result: int(result["value"]["restingHeartRate"]), + scope=FitbitScope.HEART_RATE, + state_class=SensorStateClass.MEASUREMENT, ), FitbitSensorEntityDescription( key="activities/minutesFairlyActive", @@ -184,6 +216,9 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/minutesLightlyActive", @@ -191,6 +226,9 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/minutesSedentary", @@ -198,6 +236,9 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/minutesVeryActive", @@ -205,24 +246,37 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/steps", name="Steps", native_unit_of_measurement="steps", icon="mdi:walk", + scope=FitbitScope.ACTIVITY, + state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( key="activities/tracker/activityCalories", name="Tracker Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/calories", name="Tracker Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/distance", @@ -231,6 +285,10 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/elevation", @@ -238,12 +296,20 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/floors", name="Tracker Floors", native_unit_of_measurement="floors", icon="mdi:walk", + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/minutesFairlyActive", @@ -251,6 +317,10 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/minutesLightlyActive", @@ -258,6 +328,10 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/minutesSedentary", @@ -265,6 +339,10 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/minutesVeryActive", @@ -272,12 +350,20 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/steps", name="Tracker Steps", native_unit_of_measurement="steps", icon="mdi:walk", + scope=FitbitScope.ACTIVITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="body/bmi", @@ -286,6 +372,9 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, + scope=FitbitScope.WEIGHT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="body/fat", @@ -294,6 +383,9 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, + scope=FitbitScope.WEIGHT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="body/weight", @@ -303,12 +395,16 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.WEIGHT, value_fn=_body_value_fn, unit_fn=_weight_unit, + scope=FitbitScope.WEIGHT, ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", name="Awakenings Count", native_unit_of_measurement="times awaken", icon="mdi:sleep", + scope=FitbitScope.SLEEP, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/efficiency", @@ -316,6 +412,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:sleep", state_class=SensorStateClass.MEASUREMENT, + scope=FitbitScope.SLEEP, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/minutesAfterWakeup", @@ -323,6 +421,9 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.SLEEP, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/minutesAsleep", @@ -330,6 +431,9 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.SLEEP, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/minutesAwake", @@ -337,6 +441,9 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.SLEEP, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/minutesToFallAsleep", @@ -344,6 +451,9 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.SLEEP, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/timeInBed", @@ -351,6 +461,27 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:hotel", device_class=SensorDeviceClass.DURATION, + scope=FitbitScope.SLEEP, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + FitbitSensorEntityDescription( + key="foods/log/caloriesIn", + name="Calories In", + native_unit_of_measurement="cal", + icon="mdi:food-apple", + state_class=SensorStateClass.TOTAL_INCREASING, + scope=FitbitScope.NUTRITION, + entity_category=EntityCategory.DIAGNOSTIC, + ), + FitbitSensorEntityDescription( + key="foods/log/water", + name="Water", + icon="mdi:cup-water", + unit_fn=_water_unit, + state_class=SensorStateClass.TOTAL_INCREASING, + scope=FitbitScope.NUTRITION, + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -359,18 +490,24 @@ SLEEP_START_TIME = FitbitSensorEntityDescription( key="sleep/startTime", name="Sleep Start Time", icon="mdi:clock", + scope=FitbitScope.SLEEP, + entity_category=EntityCategory.DIAGNOSTIC, ) SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( key="sleep/startTime", name="Sleep Start Time", icon="mdi:clock", value_fn=_clock_format_12h, + scope=FitbitScope.SLEEP, + entity_category=EntityCategory.DIAGNOSTIC, ) FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( key="devices/battery", name="Battery", icon="mdi:battery", + scope=FitbitScope.DEVICE, + entity_category=EntityCategory.DIAGNOSTIC, ) FITBIT_RESOURCES_KEYS: Final[list[str]] = [ @@ -397,88 +534,29 @@ PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( } ) - -def request_app_setup( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - config_path: str, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Assist user with configuring the Fitbit dev application.""" - - def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None: - """Handle configuration updates.""" - config_path = hass.config.path(FITBIT_CONFIG_FILE) - if os.path.isfile(config_path): - config_file = load_json_object(config_path) - if config_file == DEFAULT_CONFIG: - error_msg = ( - f"You didn't correctly modify {FITBIT_CONFIG_FILE}, please try" - " again." - ) - - configurator.notify_errors(hass, _CONFIGURING["fitbit"], error_msg) - else: - setup_platform(hass, config, add_entities, discovery_info) - else: - setup_platform(hass, config, add_entities, discovery_info) - - try: - description = f"""Please create a Fitbit developer app at - https://dev.fitbit.com/apps/new. - For the OAuth 2.0 Application Type choose Personal. - Set the Callback URL to {get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}. - (Note: Your Home Assistant instance must be accessible via HTTPS.) - They will provide you a Client ID and secret. - These need to be saved into the file located at: {config_path}. - Then come back here and hit the below button. - """ - except NoURLAvailableError: - _LOGGER.error( - "Could not find an SSL enabled URL for your Home Assistant instance. " - "Fitbit requires that your Home Assistant instance is accessible via HTTPS" - ) - return - - submit = f"I have saved my Client ID and Client Secret into {FITBIT_CONFIG_FILE}." - - _CONFIGURING["fitbit"] = configurator.request_config( - hass, - "Fitbit", - fitbit_configuration_callback, - description=description, - submit_caption=submit, - description_image="/static/images/config_fitbit_app.png", - ) +# Only import configuration if it was previously created successfully with all +# of the following fields. +FITBIT_CONF_KEYS = [ + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + ATTR_ACCESS_TOKEN, + ATTR_REFRESH_TOKEN, + ATTR_LAST_SAVED_AT, +] -def request_oauth_completion(hass: HomeAssistant) -> None: - """Request user complete Fitbit OAuth2 flow.""" - if "fitbit" in _CONFIGURING: - configurator.notify_errors( - hass, _CONFIGURING["fitbit"], "Failed to register, please try again." - ) - - return - - def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None: - """Handle configuration updates.""" - - start_url = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_START}" - - description = f"Please authorize Fitbit by visiting {start_url}" - - _CONFIGURING["fitbit"] = configurator.request_config( - hass, - "Fitbit", - fitbit_configuration_callback, - description=description, - submit_caption="I have authorized Fitbit.", - ) +def load_config_file(config_path: str) -> dict[str, Any] | None: + """Load existing valid fitbit.conf from disk for import.""" + if os.path.isfile(config_path): + config_file = load_json_object(config_path) + if config_file != DEFAULT_CONFIG and all( + key in config_file for key in FITBIT_CONF_KEYS + ): + return config_file + return None -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, @@ -486,182 +564,130 @@ def setup_platform( ) -> None: """Set up the Fitbit sensor.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) - if os.path.isfile(config_path): - config_file = load_json_object(config_path) - if config_file == DEFAULT_CONFIG: - request_app_setup( - hass, config, add_entities, config_path, discovery_info=None - ) - return - else: - save_json(config_path, DEFAULT_CONFIG) - request_app_setup(hass, config, add_entities, config_path, discovery_info=None) - return + config_file = await hass.async_add_executor_job(load_config_file, config_path) + _LOGGER.debug("loaded config file: %s", config_file) - if "fitbit" in _CONFIGURING: - configurator.request_done(hass, _CONFIGURING.pop("fitbit")) + if config_file is not None: + _LOGGER.debug("Importing existing fitbit.conf application credentials") - if ( - (access_token := config_file.get(ATTR_ACCESS_TOKEN)) is not None - and (refresh_token := config_file.get(ATTR_REFRESH_TOKEN)) is not None - and (expires_at := config_file.get(ATTR_LAST_SAVED_AT)) is not None - ): + # Refresh the token before importing to ensure it is working and not + # expired on first initialization. authd_client = Fitbit( - config_file.get(CONF_CLIENT_ID), - config_file.get(CONF_CLIENT_SECRET), - access_token=access_token, - refresh_token=refresh_token, - expires_at=expires_at, + config_file[CONF_CLIENT_ID], + config_file[CONF_CLIENT_SECRET], + access_token=config_file[ATTR_ACCESS_TOKEN], + refresh_token=config_file[ATTR_REFRESH_TOKEN], + expires_at=config_file[ATTR_LAST_SAVED_AT], refresh_cb=lambda x: None, ) - - if int(time.time()) - cast(int, expires_at) > 3600: - authd_client.client.refresh_token() - - api = FitbitApi(hass, authd_client, config[CONF_UNIT_SYSTEM]) - user_profile = asyncio.run_coroutine_threadsafe( - api.async_get_user_profile(), hass.loop - ).result() - unit_system = asyncio.run_coroutine_threadsafe( - api.async_get_unit_system(), hass.loop - ).result() - - clock_format = config[CONF_CLOCK_FORMAT] - monitored_resources = config[CONF_MONITORED_RESOURCES] - resource_list = [ - *FITBIT_RESOURCES_LIST, - SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME, - ] - entities = [ - FitbitSensor( - api, - user_profile.encoded_id, - config_path, - description, - units=description.unit_fn(unit_system), + try: + updated_token = await hass.async_add_executor_job( + authd_client.client.refresh_token ) - for description in resource_list - if description.key in monitored_resources - ] - if "devices/battery" in monitored_resources: - devices = asyncio.run_coroutine_threadsafe( - api.async_get_devices(), - hass.loop, - ).result() - entities.extend( - [ - FitbitSensor( - api, - user_profile.encoded_id, - config_path, - FITBIT_RESOURCE_BATTERY, - device, - ) - for device in devices - ] - ) - add_entities(entities, True) - - else: - oauth = FitbitOauth2Client( - config_file.get(CONF_CLIENT_ID), config_file.get(CONF_CLIENT_SECRET) - ) - - redirect_uri = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}" - - fitbit_auth_start_url, _ = oauth.authorize_token_url( - redirect_uri=redirect_uri, - scope=[ - "activity", - "heartrate", - "nutrition", - "profile", - "settings", - "sleep", - "weight", - ], - ) - - hass.http.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url) - hass.http.register_view(FitbitAuthCallbackView(config, add_entities, oauth)) - - request_oauth_completion(hass) - - -class FitbitAuthCallbackView(HomeAssistantView): - """Handle OAuth finish callback requests.""" - - requires_auth = False - url = FITBIT_AUTH_CALLBACK_PATH - name = "api:fitbit:callback" - - def __init__( - self, - config: ConfigType, - add_entities: AddEntitiesCallback, - oauth: FitbitOauth2Client, - ) -> None: - """Initialize the OAuth callback view.""" - self.config = config - self.add_entities = add_entities - self.oauth = oauth - - async def get(self, request: Request) -> str: - """Finish OAuth callback request.""" - hass: HomeAssistant = request.app["hass"] - data = request.query - - response_message = """Fitbit has been successfully authorized! - You can close this window now!""" - - result = None - if data.get("code") is not None: - redirect_uri = f"{get_url(hass, require_current_request=True)}{FITBIT_AUTH_CALLBACK_PATH}" - - try: - result = await hass.async_add_executor_job( - self.oauth.fetch_access_token, data.get("code"), redirect_uri - ) - except MissingTokenError as error: - _LOGGER.error("Missing token: %s", error) - response_message = f"""Something went wrong when - attempting authenticating with Fitbit. The error - encountered was {error}. Please try again!""" - except MismatchingStateError as error: - _LOGGER.error("Mismatched state, CSRF error: %s", error) - response_message = f"""Something went wrong when - attempting authenticating with Fitbit. The error - encountered was {error}. Please try again!""" + except OAuth2Error as err: + _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) + translation_key = "deprecated_yaml_import_issue_cannot_connect" else: - _LOGGER.error("Unknown error when authing") - response_message = """Something went wrong when - attempting authenticating with Fitbit. - An unknown error occurred. Please try again! - """ + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] + ), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN], + "expires_at": updated_token["expires_at"], + "scope": " ".join(updated_token.get("scope", [])), + }, + CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], + CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], + CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], + }, + ) + translation_key = "deprecated_yaml_import" + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") == "cannot_connect" + ): + translation_key = "deprecated_yaml_import_issue_cannot_connect" + else: + translation_key = "deprecated_yaml_no_import" - if result is None: - _LOGGER.error("Unknown error when authing") - response_message = """Something went wrong when - attempting authenticating with Fitbit. - An unknown error occurred. Please try again! - """ + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2024.5.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + ) - html_response = f"""Fitbit Auth -

{response_message}

""" - if result: - config_contents = { - ATTR_ACCESS_TOKEN: result.get("access_token"), - ATTR_REFRESH_TOKEN: result.get("refresh_token"), - CONF_CLIENT_ID: self.oauth.client_id, - CONF_CLIENT_SECRET: self.oauth.client_secret, - ATTR_LAST_SAVED_AT: int(time.time()), - } - save_json(hass.config.path(FITBIT_CONFIG_FILE), config_contents) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Fitbit sensor platform.""" - hass.async_add_job(setup_platform, hass, self.config, self.add_entities) + data: FitbitData = hass.data[DOMAIN][entry.entry_id] + api = data.api - return html_response + # These are run serially to reuse the cached user profile, not gathered + # to avoid two racing requests. + user_profile = await api.async_get_user_profile() + unit_system = await api.async_get_unit_system() + + fitbit_config = config_from_entry_data(entry.data) + + def is_explicit_enable(description: FitbitSensorEntityDescription) -> bool: + """Determine if entity is enabled by default.""" + return fitbit_config.is_explicit_enable(description.key) + + def is_allowed_resource(description: FitbitSensorEntityDescription) -> bool: + """Determine if an entity is allowed to be created.""" + return fitbit_config.is_allowed_resource(description.scope, description.key) + + resource_list = [ + *FITBIT_RESOURCES_LIST, + SLEEP_START_TIME_12HR + if fitbit_config.clock_format == "12H" + else SLEEP_START_TIME, + ] + + entities = [ + FitbitSensor( + entry, + api, + user_profile.encoded_id, + description, + units=description.unit_fn(unit_system), + enable_default_override=is_explicit_enable(description), + ) + for description in resource_list + if is_allowed_resource(description) + ] + async_add_entities(entities) + + if data.device_coordinator and is_allowed_resource(FITBIT_RESOURCE_BATTERY): + async_add_entities( + FitbitBatterySensor( + data.device_coordinator, + user_profile.encoded_id, + FITBIT_RESOURCE_BATTERY, + device=device, + enable_default_override=is_explicit_enable(FITBIT_RESOURCE_BATTERY), + ) + for device in data.device_coordinator.data.values() + ) class FitbitSensor(SensorEntity): @@ -672,74 +698,99 @@ class FitbitSensor(SensorEntity): def __init__( self, + config_entry: ConfigEntry, api: FitbitApi, user_profile_id: str, - config_path: str, description: FitbitSensorEntityDescription, - device: FitbitDevice | None = None, - units: str | None = None, + units: str | None, + enable_default_override: bool, ) -> None: """Initialize the Fitbit sensor.""" + self.config_entry = config_entry self.entity_description = description self.api = api - self.config_path = config_path - self.device = device + self._attr_unique_id = f"{user_profile_id}_{description.key}" + + if units is not None: + self._attr_native_unit_of_measurement = units + + if enable_default_override: + self._attr_entity_registry_enabled_default = True + + async def async_update(self) -> None: + """Get the latest data from the Fitbit API and update the states.""" + try: + result = await self.api.async_get_latest_time_series( + self.entity_description.key + ) + except FitbitAuthException: + self._attr_available = False + self.config_entry.async_start_reauth(self.hass) + except FitbitApiException: + self._attr_available = False + else: + self._attr_available = True + self._attr_native_value = self.entity_description.value_fn(result) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # We do not ask for an update with async_add_entities() + # because it will update disabled entities. + self.async_schedule_update_ha_state(force_refresh=True) + + +class FitbitBatterySensor(CoordinatorEntity, SensorEntity): + """Implementation of a Fitbit sensor.""" + + entity_description: FitbitSensorEntityDescription + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: FitbitDeviceCoordinator, + user_profile_id: str, + description: FitbitSensorEntityDescription, + device: FitbitDevice, + enable_default_override: bool, + ) -> None: + """Initialize the Fitbit sensor.""" + super().__init__(coordinator) + self.entity_description = description + self.device = device self._attr_unique_id = f"{user_profile_id}_{description.key}" if device is not None: self._attr_name = f"{device.device_version} Battery" self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" - if units is not None: - self._attr_native_unit_of_measurement = units + if enable_default_override: + self._attr_entity_registry_enabled_default = True @property def icon(self) -> str | None: """Icon to use in the frontend, if any.""" - if ( - self.entity_description.key == "devices/battery" - and self.device is not None - and (battery_level := BATTERY_LEVELS.get(self.device.battery)) is not None - ): + if battery_level := BATTERY_LEVELS.get(self.device.battery): return icon_for_battery_level(battery_level=battery_level) return self.entity_description.icon @property def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" - attrs: dict[str, str | None] = {} - - if self.device is not None: - attrs["model"] = self.device.device_version - device_type = self.device.type - attrs["type"] = device_type.lower() if device_type is not None else None - - return attrs - - async def async_update(self) -> None: - """Get the latest data from the Fitbit API and update the states.""" - resource_type = self.entity_description.key - if resource_type == "devices/battery" and self.device is not None: - device_id = self.device.id - registered_devs: list[FitbitDevice] = await self.api.async_get_devices() - self.device = next( - device for device in registered_devs if device.id == device_id - ) - self._attr_native_value = self.device.battery - - else: - result = await self.api.async_get_latest_time_series(resource_type) - self._attr_native_value = self.entity_description.value_fn(result) - - self.hass.async_add_executor_job(self._update_token) - - def _update_token(self) -> None: - token = self.api.client.client.session.token - config_contents = { - ATTR_ACCESS_TOKEN: token.get("access_token"), - ATTR_REFRESH_TOKEN: token.get("refresh_token"), - CONF_CLIENT_ID: self.api.client.client.client_id, - CONF_CLIENT_SECRET: self.api.client.client.client_secret, - ATTR_LAST_SAVED_AT: int(time.time()), + return { + "model": self.device.device_version, + "type": self.device.type.lower() if self.device.type is not None else None, } - save_json(self.config_path, config_contents) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.device = self.coordinator.data[self.device.id] + self._attr_native_value = self.device.battery + self.async_write_ha_state() diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json new file mode 100644 index 00000000000..889b56f1bbd --- /dev/null +++ b/homeassistant/components/fitbit/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "auth": { + "title": "Link Fitbit" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Fitbit integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "The user credentials provided do not match this Fitbit account." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "issues": { + "deprecated_yaml_no_import": { + "title": "Fitbit YAML configuration is being removed", + "description": "Configuring Fitbit using YAML is being removed.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf if it exists and restart Home Assistant and [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." + }, + "deprecated_yaml_import": { + "title": "Fitbit YAML configuration is being removed", + "description": "Configuring Fitbit using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically, including OAuth Application Credentials.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Fitbit YAML configuration import failed", + "description": "Configuring Fitbit using YAML is being removed but there was a connection error importing your YAML configuration.\n\nRestart Home Assistant to try again or remove the Fitbit YAML configuration from your configuration.yaml file and remove the fitbit.conf and continue to [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." + } + } +} diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index a8618b2df87..66078c50c1a 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -25,8 +25,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="ph", - translation_key="ph", icon="mdi:pool", + device_class=SensorDeviceClass.PH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json index 24557ff177b..235117afbd4 100644 --- a/homeassistant/components/flipr/strings.json +++ b/homeassistant/components/flipr/strings.json @@ -40,9 +40,6 @@ "chlorine": { "name": "Chlorine" }, - "ph": { - "name": "pH" - }, "water_temperature": { "name": "Water temperature" }, diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index 066ffef6a05..2745f5f9fb7 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -32,6 +32,7 @@ class FloEntity(Entity): return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, self._device.mac_address)}, identifiers={(FLO_DOMAIN, self._device.id)}, + serial_number=self._device.serial_number, manufacturer=self._device.manufacturer, model=self._device.model, name=self._device.device_name.capitalize(), diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 294f50c50e2..9a96233e6a9 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -2,6 +2,7 @@ from pyflume import FlumeAuth, FlumeDeviceList from requests import Session from requests.exceptions import RequestException +import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -10,8 +11,14 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( BASE_TOKEN_FILENAME, @@ -19,8 +26,18 @@ from .const import ( FLUME_AUTH, FLUME_DEVICES, FLUME_HTTP_SESSION, + FLUME_NOTIFICATIONS_COORDINATOR, PLATFORMS, ) +from .coordinator import FlumeNotificationDataUpdateCoordinator + +SERVICE_LIST_NOTIFICATIONS = "list_notifications" +CONF_CONFIG_ENTRY = "config_entry" +LIST_NOTIFICATIONS_SERVICE_SCHEMA = vol.All( + { + vol.Required(CONF_CONFIG_ENTRY): ConfigEntrySelector(), + }, +) def _setup_entry(hass: HomeAssistant, entry: ConfigEntry): @@ -59,14 +76,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: flume_auth, flume_devices, http_session = await hass.async_add_executor_job( _setup_entry, hass, entry ) + notification_coordinator = FlumeNotificationDataUpdateCoordinator( + hass=hass, auth=flume_auth + ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { FLUME_DEVICES: flume_devices, FLUME_AUTH: flume_auth, FLUME_HTTP_SESSION: http_session, + FLUME_NOTIFICATIONS_COORDINATOR: notification_coordinator, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await async_setup_service(hass) return True @@ -81,3 +103,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_setup_service(hass: HomeAssistant) -> None: + """Add the services for the flume integration.""" + + async def list_notifications(call: ServiceCall) -> ServiceResponse: + """Return the user notifications.""" + entry_id: str = call.data[CONF_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + if not entry: + raise ValueError(f"Invalid config entry: {entry_id}") + if not (flume_domain_data := hass.data[DOMAIN].get(entry_id)): + raise ValueError(f"Config entry not loaded: {entry_id}") + return { + "notifications": flume_domain_data[ + FLUME_NOTIFICATIONS_COORDINATOR + ].notifications + } + + hass.services.async_register( + DOMAIN, + SERVICE_LIST_NOTIFICATIONS, + list_notifications, + schema=LIST_NOTIFICATIONS_SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index c912c3419d7..2305cd9f23e 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -15,8 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DOMAIN, - FLUME_AUTH, FLUME_DEVICES, + FLUME_NOTIFICATIONS_COORDINATOR, FLUME_TYPE_BRIDGE, FLUME_TYPE_SENSOR, KEY_DEVICE_ID, @@ -84,7 +84,6 @@ async def async_setup_entry( ) -> None: """Set up a Flume binary sensor..""" flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] - flume_auth = flume_domain_data[FLUME_AUTH] flume_devices = flume_domain_data[FLUME_DEVICES] flume_entity_list: list[ @@ -94,9 +93,7 @@ async def async_setup_entry( connection_coordinator = FlumeDeviceConnectionUpdateCoordinator( hass=hass, flume_devices=flume_devices ) - notification_coordinator = FlumeNotificationDataUpdateCoordinator( - hass=hass, auth=flume_auth - ) + notification_coordinator = flume_domain_data[FLUME_NOTIFICATIONS_COORDINATOR] flume_devices = get_valid_flume_devices(flume_devices) for device in flume_devices: device_id = device[KEY_DEVICE_ID] diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index 9e932cce4dd..a4e7dba444e 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -29,7 +29,7 @@ FLUME_TYPE_SENSOR = 2 FLUME_AUTH = "flume_auth" FLUME_HTTP_SESSION = "http_session" FLUME_DEVICES = "devices" - +FLUME_NOTIFICATIONS_COORDINATOR = "notifications_coordinator" CONF_TOKEN_FILE = "token_filename" BASE_TOKEN_FILENAME = "FLUME_TOKEN_FILE" diff --git a/homeassistant/components/flume/services.yaml b/homeassistant/components/flume/services.yaml new file mode 100644 index 00000000000..e6f3d908a09 --- /dev/null +++ b/homeassistant/components/flume/services.yaml @@ -0,0 +1,7 @@ +list_notifications: + fields: + config_entry: + required: true + selector: + config_entry: + integration: flume diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json index 2c1a900c091..5f3021960b5 100644 --- a/homeassistant/components/flume/strings.json +++ b/homeassistant/components/flume/strings.json @@ -61,5 +61,17 @@ "name": "30 days" } } + }, + "services": { + "list_notifications": { + "name": "List notifications", + "description": "Return user notifications.", + "fields": { + "config_entry": { + "name": "Flume", + "description": "The flume config entry for which to return notifications." + } + } + } } } diff --git a/homeassistant/components/flux_led/services.yaml b/homeassistant/components/flux_led/services.yaml index 73f479825da..8311be5b9a8 100644 --- a/homeassistant/components/flux_led/services.yaml +++ b/homeassistant/components/flux_led/services.yaml @@ -113,9 +113,9 @@ set_music_mode: example: "[255, 100, 100]" required: false selector: - object: + color_rgb: background_color: example: "[255, 100, 100]" required: false selector: - object: + color_rgb: diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index bbe0f452bea..1ec62c54b6c 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -1,7 +1,7 @@ { "domain": "fronius", "name": "Fronius", - "codeowners": ["@nielstron", "@farmio"], + "codeowners": ["@farmio"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 6d5e43a94ee..dfc76ae1415 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -99,6 +99,9 @@ class FroniusSensorEntityDescription(SensorEntityDescription): """Describes Fronius sensor entity.""" default_value: StateType | None = None + # Gen24 devices may report 0 for total energy while doing firmware updates. + # Handling such values shall mitigate spikes in delta calculations. + invalid_when_falsy: bool = False INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ @@ -119,6 +122,7 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="frequency_ac", @@ -253,6 +257,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_reactive_ac_produced", @@ -260,6 +265,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_ac_minus", @@ -267,6 +273,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_ac_plus", @@ -274,18 +281,21 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="frequency_phase_average", @@ -461,6 +471,7 @@ OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="power_real_ac", @@ -508,6 +519,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, entity_registry_enabled_default=False, ), FroniusSensorEntityDescription( @@ -648,6 +660,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn ]["value"] if new_value is None: return self.entity_description.default_value + if self.entity_description.invalid_when_falsy and not new_value: + raise ValueError(f"Ignoring zero value for {self.entity_id}.") if isinstance(new_value, float): return round(new_value, 4) return new_value @@ -657,8 +671,10 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn """Handle updated data from the coordinator.""" try: self._attr_native_value = self._get_entity_value() - except KeyError: + except (KeyError, ValueError): # sets state to `None` if no default_value is defined in entity description + # KeyError: raised when omitted in response - eg. at night when no production + # ValueError: raised when invalid zero value received self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index a5a4d76f9e7..2ec991750f0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -17,7 +17,12 @@ from homeassistant.components import onboarding, websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config import async_hass_config_yaml -from homeassistant.const import CONF_MODE, CONF_NAME, EVENT_THEMES_UPDATED +from homeassistant.const import ( + CONF_MODE, + CONF_NAME, + EVENT_PANELS_UPDATED, + EVENT_THEMES_UPDATED, +) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv @@ -40,7 +45,6 @@ CONF_EXTRA_MODULE_URL = "extra_module_url" CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5" CONF_FRONTEND_REPO = "development_repo" CONF_JS_VERSION = "javascript_version" -EVENT_PANELS_UPDATED = "panels_updated" DEFAULT_THEME_COLOR = "#03A9F4" @@ -384,6 +388,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Can be removed in 2023 hass.http.register_redirect("/config/server_control", "/developer-tools/yaml") + # Shopping list panel was replaced by todo panel in 2023.11 + hass.http.register_redirect("/shopping-list", "/todo") + hass.http.app.router.register_resource(IndexView(repo_path, hass)) async_register_built_in_panel(hass, "profile") @@ -589,7 +596,7 @@ class IndexView(web_urldispatcher.AbstractResource): async def get(self, request: web.Request) -> web.Response: """Serve the index page for panel pages.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] if not onboarding.async_is_onboarded(hass): return web.Response(status=302, headers={"location": "/onboarding.html"}) @@ -598,12 +605,20 @@ class IndexView(web_urldispatcher.AbstractResource): self.get_template ) + extra_modules: frozenset[str] + extra_js_es5: frozenset[str] + if hass.config.safe_mode: + extra_modules = frozenset() + extra_js_es5 = frozenset() + else: + extra_modules = hass.data[DATA_EXTRA_MODULE_URL].urls + extra_js_es5 = hass.data[DATA_EXTRA_JS_URL_ES5].urls return web.Response( text=_async_render_index_cached( template, theme_color=MANIFEST_JSON["theme_color"], - extra_modules=hass.data[DATA_EXTRA_MODULE_URL].urls, - extra_js_es5=hass.data[DATA_EXTRA_JS_URL_ES5].urls, + extra_modules=extra_modules, + extra_js_es5=extra_js_es5, ), content_type="text/html", ) @@ -654,18 +669,13 @@ def websocket_get_themes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get themes command.""" - if hass.config.safe_mode: + if hass.config.recovery_mode or hass.config.safe_mode: connection.send_message( websocket_api.result_message( msg["id"], { - "themes": { - "safe_mode": { - "primary-color": "#db4437", - "accent-color": "#ffca28", - } - }, - "default_theme": "safe_mode", + "themes": {}, + "default_theme": "default", }, ) ) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 0d1c1659471..6fffc0e8acd 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==20231005.0"] + "requirements": ["home-assistant-frontend==20231030.1"] } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 641a267e987..223abe26e55 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -126,7 +126,8 @@ class AFSAPIDevice(MediaPlayerEntity): if not self._attr_source_list: self.__modes_by_label = { - mode.label: mode.key for mode in await afsapi.get_modes() + (mode.label if mode.label else mode.id): mode.key + for mode in await afsapi.get_modes() } self._attr_source_list = list(self.__modes_by_label) diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 01eac80d1e0..d0c1b878cef 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -4,7 +4,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "confirm": { diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index f25341455bb..557af9474ed 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -13,11 +13,10 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, UnitOfLength, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -78,21 +77,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: }, ) ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Global Disaster Alert and Coordination System", - }, - ) - return True diff --git a/homeassistant/components/gdacs/config_flow.py b/homeassistant/components/gdacs/config_flow.py index c49626557f4..fb2b8416937 100644 --- a/homeassistant/components/gdacs/config_flow.py +++ b/homeassistant/components/gdacs/config_flow.py @@ -10,7 +10,10 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, ) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResultType from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_CATEGORIES, DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -32,7 +35,23 @@ class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) + result = await self.async_step_user(import_config) + if result["type"] == FlowResultType.CREATE_ENTRY: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Global Disaster Alert and Coordination System", + }, + ) + return result async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" @@ -48,7 +67,25 @@ class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" await self.async_set_unique_id(identifier) - self._abort_if_unique_id_configured() + try: + self._abort_if_unique_id_configured() + except AbortFlow: + if self.context["source"] == config_entries.SOURCE_IMPORT: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Global Disaster Alert and Coordination System", + }, + ) + raise scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) user_input[CONF_SCAN_INTERVAL] = scan_interval.total_seconds() diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 2966d668ac9..7ac9c5d406f 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.1.1", "Pillow==10.0.1"] + "requirements": ["ha-av==10.1.1", "Pillow==10.1.0"] } diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index 0821e8798c7..585d0aa1fe3 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -2,7 +2,7 @@ import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass -from homeassistant.const import CONF_NAME, Platform +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType @@ -24,6 +24,7 @@ CONF_AWAY_HUMIDITY = "away_humidity" CONF_AWAY_FIXED = "away_fixed" CONF_STALE_DURATION = "sensor_stale_duration" + DEFAULT_TOLERANCE = 3 DEFAULT_NAME = "Generic Hygrostat" @@ -48,6 +49,7 @@ HYGROSTAT_SCHEMA = vol.Schema( vol.Optional(CONF_STALE_DURATION): vol.All( cv.time_period, cv.positive_timedelta ), + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 959b0a8e8df..3bdecbfa997 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME, + CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_START, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -86,6 +87,7 @@ async def async_setup_platform( initial_state = config.get(CONF_INITIAL_STATE) away_humidity = config.get(CONF_AWAY_HUMIDITY) away_fixed = config.get(CONF_AWAY_FIXED) + unique_id = config.get(CONF_UNIQUE_ID) async_add_entities( [ @@ -105,6 +107,7 @@ async def async_setup_platform( away_humidity, away_fixed, sensor_stale_duration, + unique_id, ) ] ) @@ -132,6 +135,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): away_humidity, away_fixed, sensor_stale_duration, + unique_id, ): """Initialize the hygrostat.""" self._name = name @@ -160,6 +164,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): if not self._device_class: self._device_class = HumidifierDeviceClass.HUMIDIFIER self._attr_action = HumidifierAction.IDLE + self._attr_unique_id = unique_id async def async_added_to_hass(self): """Run when entity about to be added.""" diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py index f00019361e5..a2f71bfd9fe 100644 --- a/homeassistant/components/geniushub/binary_sensor.py +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN, GeniusDevice GH_STATE_ATTR = "outputOnOff" +GH_TYPE = "Receiver" async def async_setup_platform( @@ -26,7 +27,7 @@ async def async_setup_platform( switches = [ GeniusBinarySensor(broker, d, GH_STATE_ATTR) for d in broker.client.device_objs - if GH_STATE_ATTR in d.data["state"] + if GH_TYPE in d.data["type"] ] async_add_entities(switches, update_before_add=True) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 4029023bb07..28079293821 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/geniushub", "iot_class": "local_polling", "loggers": ["geniushubclient"], - "requirements": ["geniushub-client==0.7.0"] + "requirements": ["geniushub-client==0.7.1"] } diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 41954645f5c..fece0b09f60 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==3.1.0"] + "requirements": ["gios==3.2.0"] } diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index db96fa0539b..6d53628f21e 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -21,23 +21,22 @@ PARALLEL_UPDATES = 0 BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="backlight", - name="Backlight", + translation_key="backlight", icon="mdi:clock-digital", ), BinarySensorEntityDescription( key="app_online", - name="App online", + translation_key="app_online", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensorEntityDescription( key="isCharging", - name="Charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ), BinarySensorEntityDescription( key="inputDetected", - name="Input detected", + translation_key="input_detected", device_class=BinarySensorDeviceClass.POWER, ), ) diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 9001824d678..b9a83453d7f 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -31,14 +31,14 @@ from .entity import GoalZeroEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="wattsIn", - name="Watts in", + translation_key="watts_in", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ampsIn", - name="Amps in", + translation_key="amps_in", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -46,14 +46,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="wattsOut", - name="Watts out", + translation_key="watts_out", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ampsOut", - name="Amps out", + translation_key="amps_out", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -61,7 +61,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="whOut", - name="Wh out", + translation_key="wh_out", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -69,40 +69,38 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="whStored", - name="Wh stored", + translation_key="wh_stored", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="volts", - name="Volts", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="socPercent", - name="State of charge percent", + translation_key="soc_percent", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="timeToEmptyFull", - name="Time to empty/full", + translation_key="time_to_empty_full", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="wifiStrength", - name="Wi-Fi strength", + translation_key="wifi_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, entity_registry_enabled_default=False, @@ -110,20 +108,20 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="timestamp", - name="Total run time", + translation_key="timestamp", native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="ssid", - name="Wi-Fi SSID", + translation_key="ssid", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="ipAddr", - name="IP address", + translation_key="ip_addr", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index 619b379c7a3..d94f5219607 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -22,5 +22,67 @@ "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "binary_sensor": { + "backlight": { + "name": "Backlight" + }, + "app_online": { + "name": "App online" + }, + "input_detected": { + "name": "Input detected" + } + }, + "sensor": { + "watts_in": { + "name": "Power in" + }, + "amps_in": { + "name": "Current in" + }, + "watts_out": { + "name": "Power out" + }, + "amps_out": { + "name": "Current out" + }, + "wh_out": { + "name": "Energy out" + }, + "wh_stored": { + "name": "Energy stored" + }, + "soc_percent": { + "name": "State of charge percent" + }, + "time_to_empty_full": { + "name": "Time to empty/full" + }, + "wifi_strength": { + "name": "Wi-Fi strength" + }, + "timestamp": { + "name": "Total run time" + }, + "ssid": { + "name": "Wi-Fi SSID" + }, + "ip_addr": { + "name": "IP address" + } + }, + "switch": { + "v12_port_status": { + "name": "12V port status" + }, + "usb_port_status": { + "name": "USB port status" + }, + "ac_port_status": { + "name": "AC port status" + } + } } } diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index ac4872bba32..30680c6ff72 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -14,15 +14,15 @@ from .entity import GoalZeroEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="v12PortStatus", - name="12V port status", + translation_key="v12_port_status", ), SwitchEntityDescription( key="usbPortStatus", - name="USB port status", + translation_key="usb_port_status", ), SwitchEntityDescription( key="acPortStatus", - name="AC port status", + translation_key="ac_port_status", ), ) diff --git a/homeassistant/components/google/diagnostics.py b/homeassistant/components/google/diagnostics.py new file mode 100644 index 00000000000..0313e61bc8e --- /dev/null +++ b/homeassistant/components/google/diagnostics.py @@ -0,0 +1,55 @@ +"""Provides diagnostics for google calendar.""" + +import datetime +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import DATA_STORE, DOMAIN + +TO_REDACT = { + "id", + "ical_uuid", + "summary", + "description", + "location", + "attendees", + "recurring_event_id", +} + + +def redact_store(data: dict[str, Any]) -> dict[str, Any]: + """Redact personal information from calendar events in the store.""" + id_num = 0 + diagnostics = {} + for store_data in data.values(): + local_store: dict[str, Any] = store_data.get("event_sync", {}) + for calendar_data in local_store.values(): + id_num += 1 + items: dict[str, Any] = calendar_data.get("items", {}) + diagnostics[f"calendar#{id_num}"] = { + "events": [ + async_redact_data(item, TO_REDACT) for item in items.values() + ], + "sync_token_version": calendar_data.get("sync_token_version"), + } + return diagnostics + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + payload: dict[str, Any] = { + "now": dt_util.now().isoformat(), + "timezone": str(dt_util.DEFAULT_TIME_ZONE), + "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), + } + + store = hass.data[DOMAIN][config_entry.entry_id][DATA_STORE] + data = await store.async_load() + payload["store"] = redact_store(data) + return payload diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index d5329598655..509100a5174 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==4.1.4", "oauth2client==4.1.3"] + "requirements": ["gcal-sync==5.0.0", "oauth2client==4.1.3"] } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index ee8e5872348..b2cda5522ee 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -240,7 +240,7 @@ class AbstractConfig(ABC): async def async_sync_notification( self, agent_user_id: str, event_id: str, payload: dict[str, Any] ) -> HTTPStatus: - """Sync notification to Google.""" + """Sync notifications to Google.""" # Remove any pending sync self._google_sync_unsub.pop(agent_user_id, lambda: None)() status = await self.async_report_state(payload, agent_user_id, event_id) diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 87af12ad0fc..aec50011200 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -80,7 +80,15 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig ): return - if (notifications := entity.notifications_serialize()) is not None: + # We only trigger notifications on changes in the state value, not attributes. + # This is mainly designed for our event entity types + # We need to synchronize notifications using a `SYNC` response, + # together with other state changes. + if ( + old_state + and old_state.state != new_state.state + and (notifications := entity.notifications_serialize()) is not None + ): event_id = uuid4().hex payload = { "devices": {"notifications": {entity.state.entity_id: notifications}} diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index a39dfd3f3dc..33f0d7a3329 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -382,10 +382,14 @@ class ObjectDetection(_Trait): return None # Only notify if last event was less then 30 seconds ago - time_stamp = datetime.fromisoformat(self.state.state) + time_stamp: datetime = datetime.fromisoformat(self.state.state) if (utcnow() - time_stamp) > timedelta(seconds=30): return None + # A doorbell event is treated as an object detection of 1 unclassified object. + # The implementation follows the pattern from the Smart Home Doorbell Guide: + # https://developers.home.google.com/cloud-to-cloud/guides/doorbell + # The detectionTimestamp is the time in ms from January 1, 1970, 00:00:00 (UTC) return { "ObjectDetection": { "objects": { @@ -1765,8 +1769,10 @@ class ModesTrait(_Trait): elif self.state.domain == humidifier.DOMAIN: if ATTR_MODE in attrs: mode_settings["mode"] = attrs.get(ATTR_MODE) - elif self.state.domain == light.DOMAIN and light.ATTR_EFFECT in attrs: - mode_settings["effect"] = attrs.get(light.ATTR_EFFECT) + elif self.state.domain == light.DOMAIN and ( + effect := attrs.get(light.ATTR_EFFECT) + ): + mode_settings["effect"] = effect if mode_settings: response["on"] = self.state.state not in (STATE_OFF, STATE_UNKNOWN) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 15c4192ccf5..96639e4a547 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -1,12 +1,9 @@ """Support for Google Mail.""" from __future__ import annotations -from aiohttp.client_exceptions import ClientError, ClientResponseError - from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -35,16 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth(session) - try: - await auth.check_and_refresh_token() - except ClientResponseError as err: - if 400 <= err.status < 500: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from err - raise ConfigEntryNotReady from err - except ClientError as err: - raise ConfigEntryNotReady from err + await auth.check_and_refresh_token() hass.data[DOMAIN][entry.entry_id] = auth hass.async_create_task( diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index ffa33deae14..10b2fec7467 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -1,9 +1,16 @@ """API for Google Mail bound to Home Assistant OAuth.""" +from aiohttp.client_exceptions import ClientError, ClientResponseError from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import config_entry_oauth2_flow @@ -24,14 +31,30 @@ class AsyncConfigEntryAuth: async def check_and_refresh_token(self) -> str: """Check the token.""" - await self.oauth_session.async_ensure_token_valid() + try: + await self.oauth_session.async_ensure_token_valid() + except (RefreshError, ClientResponseError, ClientError) as ex: + if ( + self.oauth_session.config_entry.state + is ConfigEntryState.SETUP_IN_PROGRESS + ): + if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from ex + raise ConfigEntryNotReady from ex + if ( + isinstance(ex, RefreshError) + or hasattr(ex, "status") + and ex.status == 400 + ): + self.oauth_session.config_entry.async_start_reauth( + self.oauth_session.hass + ) + raise HomeAssistantError(ex) from ex return self.access_token async def get_resource(self) -> Resource: """Get current resource.""" - try: - credentials = Credentials(await self.check_and_refresh_token()) - except RefreshError as ex: - self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) - raise ex + credentials = Credentials(await self.check_and_refresh_token()) return build("gmail", "v1", credentials=credentials) diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py new file mode 100644 index 00000000000..da6fc85b287 --- /dev/null +++ b/homeassistant/components/google_tasks/__init__.py @@ -0,0 +1,46 @@ +"""The Google Tasks integration.""" +from __future__ import annotations + +from aiohttp import ClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.TODO] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Tasks from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth(hass, session) + try: + await auth.async_get_access_token() + except ClientError as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][entry.entry_id] = auth + + 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/google_tasks/api.py b/homeassistant/components/google_tasks/api.py new file mode 100644 index 00000000000..d42926c3bf6 --- /dev/null +++ b/homeassistant/components/google_tasks/api.py @@ -0,0 +1,81 @@ +"""API for Google Tasks bound to Home Assistant OAuth.""" + +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import Resource, build +from googleapiclient.http import HttpRequest + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +MAX_TASK_RESULTS = 100 + + +class AsyncConfigEntryAuth: + """Provide Google Tasks authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth2_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Google Tasks Auth.""" + self._hass = hass + self._oauth_session = oauth2_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token[CONF_ACCESS_TOKEN] + + async def _get_service(self) -> Resource: + """Get current resource.""" + token = await self.async_get_access_token() + return build("tasks", "v1", credentials=Credentials(token=token)) + + async def list_task_lists(self) -> list[dict[str, Any]]: + """Get all TaskList resources.""" + service = await self._get_service() + cmd: HttpRequest = service.tasklists().list() + result = await self._hass.async_add_executor_job(cmd.execute) + return result["items"] + + async def list_tasks(self, task_list_id: str) -> list[dict[str, Any]]: + """Get all Task resources for the task list.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().list( + tasklist=task_list_id, maxResults=MAX_TASK_RESULTS + ) + result = await self._hass.async_add_executor_job(cmd.execute) + return result["items"] + + async def insert( + self, + task_list_id: str, + task: dict[str, Any], + ) -> None: + """Create a new Task resource on the task list.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().insert( + tasklist=task_list_id, + body=task, + ) + await self._hass.async_add_executor_job(cmd.execute) + + async def patch( + self, + task_list_id: str, + task_id: str, + task: dict[str, Any], + ) -> None: + """Update a task resource.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().patch( + tasklist=task_list_id, + task=task_id, + body=task, + ) + await self._hass.async_add_executor_job(cmd.execute) diff --git a/homeassistant/components/google_tasks/application_credentials.py b/homeassistant/components/google_tasks/application_credentials.py new file mode 100644 index 00000000000..223e723f258 --- /dev/null +++ b/homeassistant/components/google_tasks/application_credentials.py @@ -0,0 +1,23 @@ +"""Application credentials platform for the Google Tasks integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_tasks/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_tasks/config_flow.py b/homeassistant/components/google_tasks/config_flow.py new file mode 100644 index 00000000000..b8e5e26f42c --- /dev/null +++ b/homeassistant/components/google_tasks/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Google Tasks.""" +import logging +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from googleapiclient.http import HttpRequest + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, OAUTH2_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Tasks OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow.""" + try: + resource = build( + "tasks", + "v1", + credentials=Credentials(token=data[CONF_TOKEN][CONF_ACCESS_TOKEN]), + ) + cmd: HttpRequest = resource.tasklists().list() + await self.hass.async_add_executor_job(cmd.execute) + except HttpError as ex: + error = ex.reason + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": error}, + ) + except Exception as ex: # pylint: disable=broad-except + self.logger.exception("Unknown error occurred: %s", ex) + return self.async_abort(reason="unknown") + return self.async_create_entry(title=self.flow_impl.name, data=data) diff --git a/homeassistant/components/google_tasks/const.py b/homeassistant/components/google_tasks/const.py new file mode 100644 index 00000000000..87253486127 --- /dev/null +++ b/homeassistant/components/google_tasks/const.py @@ -0,0 +1,16 @@ +"""Constants for the Google Tasks integration.""" + +from enum import StrEnum + +DOMAIN = "google_tasks" + +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" +OAUTH2_SCOPES = ["https://www.googleapis.com/auth/tasks"] + + +class TaskStatus(StrEnum): + """Status of a Google Task.""" + + NEEDS_ACTION = "needsAction" + COMPLETED = "completed" diff --git a/homeassistant/components/google_tasks/coordinator.py b/homeassistant/components/google_tasks/coordinator.py new file mode 100644 index 00000000000..5377e2be567 --- /dev/null +++ b/homeassistant/components/google_tasks/coordinator.py @@ -0,0 +1,38 @@ +"""Coordinator for fetching data from Google Tasks API.""" + +import asyncio +import datetime +import logging +from typing import Any, Final + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AsyncConfigEntryAuth + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) +TIMEOUT = 10 + + +class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): + """Coordinator for fetching Google Tasks for a Task List form the API.""" + + def __init__( + self, hass: HomeAssistant, api: AsyncConfigEntryAuth, task_list_id: str + ) -> None: + """Initialize TaskUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"Google Tasks {task_list_id}", + update_interval=UPDATE_INTERVAL, + ) + self.api = api + self._task_list_id = task_list_id + + async def _async_update_data(self) -> list[dict[str, Any]]: + """Fetch tasks from API endpoint.""" + async with asyncio.timeout(TIMEOUT): + return await self.api.list_tasks(self._task_list_id) diff --git a/homeassistant/components/google_tasks/manifest.json b/homeassistant/components/google_tasks/manifest.json new file mode 100644 index 00000000000..08f2a54d051 --- /dev/null +++ b/homeassistant/components/google_tasks/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "google_tasks", + "name": "Google Tasks", + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_tasks", + "iot_class": "cloud_polling", + "requirements": ["google-api-python-client==2.71.0"] +} diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json new file mode 100644 index 00000000000..f15c31f42d4 --- /dev/null +++ b/homeassistant/components/google_tasks/strings.json @@ -0,0 +1,26 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Tasks. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py new file mode 100644 index 00000000000..5d2da33da71 --- /dev/null +++ b/homeassistant/components/google_tasks/todo.py @@ -0,0 +1,116 @@ +"""Google Tasks todo platform.""" +from __future__ import annotations + +from datetime import timedelta +from typing import cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import TaskUpdateCoordinator + +SCAN_INTERVAL = timedelta(minutes=15) + +TODO_STATUS_MAP = { + "needsAction": TodoItemStatus.NEEDS_ACTION, + "completed": TodoItemStatus.COMPLETED, +} +TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()} + + +def _convert_todo_item(item: TodoItem) -> dict[str, str]: + """Convert TodoItem dataclass items to dictionary of attributes the tasks API.""" + result: dict[str, str] = {} + if item.summary is not None: + result["title"] = item.summary + if item.status is not None: + result["status"] = TODO_STATUS_MAP_INV[item.status] + return result + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Google Tasks todo platform.""" + api: AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id] + task_lists = await api.list_task_lists() + async_add_entities( + ( + GoogleTaskTodoListEntity( + TaskUpdateCoordinator(hass, api, task_list["id"]), + task_list["title"], + entry.entry_id, + task_list["id"], + ) + for task_list in task_lists + ), + True, + ) + + +class GoogleTaskTodoListEntity( + CoordinatorEntity[TaskUpdateCoordinator], TodoListEntity +): + """A To-do List representation of the Shopping List.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM + ) + + def __init__( + self, + coordinator: TaskUpdateCoordinator, + name: str, + config_entry_id: str, + task_list_id: str, + ) -> None: + """Initialize LocalTodoListEntity.""" + super().__init__(coordinator) + self._attr_name = name.capitalize() + self._attr_unique_id = f"{config_entry_id}-{task_list_id}" + self._task_list_id = task_list_id + + @property + def todo_items(self) -> list[TodoItem] | None: + """Get the current set of To-do items.""" + if self.coordinator.data is None: + return None + return [ + TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status"), TodoItemStatus.NEEDS_ACTION # type: ignore[arg-type] + ), + ) + for item in self.coordinator.data + ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + await self.coordinator.api.insert( + self._task_list_id, + task=_convert_todo_item(item), + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + await self.coordinator.api.patch( + self._task_list_id, + uid, + task=_convert_todo_item(item), + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 23d4f2541bd..83e144f6bbd 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -8,12 +8,17 @@ from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME 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 ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( ALL_LANGUAGES, ARRIVAL_TIME, - AVOID, + AVOID_OPTIONS, CONF_ARRIVAL_TIME, CONF_AVOID, CONF_DEPARTURE_TIME, @@ -30,18 +35,87 @@ from .const import ( DEPARTURE_TIME, DOMAIN, TIME_TYPES, + TRAFFIC_MODELS, TRANSIT_PREFS, - TRANSPORT_TYPE, - TRAVEL_MODE, - TRAVEL_MODEL, + TRANSPORT_TYPES, + TRAVEL_MODES, UNITS, UNITS_IMPERIAL, UNITS_METRIC, ) from .helpers import InvalidApiKeyException, UnknownException, validate_config_entry +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_MODE): SelectSelector( + SelectSelectorConfig( + options=TRAVEL_MODES, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_MODE, + ) + ), + vol.Optional(CONF_LANGUAGE): SelectSelector( + SelectSelectorConfig( + options=sorted(ALL_LANGUAGES), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_LANGUAGE, + ) + ), + vol.Optional(CONF_AVOID): SelectSelector( + SelectSelectorConfig( + options=AVOID_OPTIONS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_AVOID, + ) + ), + vol.Required(CONF_UNITS): SelectSelector( + SelectSelectorConfig( + options=UNITS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_UNITS, + ) + ), + vol.Required(CONF_TIME_TYPE): SelectSelector( + SelectSelectorConfig( + options=TIME_TYPES, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TIME_TYPE, + ) + ), + vol.Optional(CONF_TIME, default=""): cv.string, + vol.Optional(CONF_TRAFFIC_MODEL): SelectSelector( + SelectSelectorConfig( + options=TRAFFIC_MODELS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TRAFFIC_MODEL, + ) + ), + vol.Optional(CONF_TRANSIT_MODE): SelectSelector( + SelectSelectorConfig( + options=TRANSPORT_TYPES, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TRANSIT_MODE, + ) + ), + vol.Optional(CONF_TRANSIT_ROUTING_PREFERENCE): SelectSelector( + SelectSelectorConfig( + options=TRANSIT_PREFS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TRANSIT_ROUTING_PREFERENCE, + ) + ), + } +) -def default_options(hass: HomeAssistant) -> dict[str, str | None]: + +def default_options(hass: HomeAssistant) -> dict[str, str]: """Get the default options.""" return { CONF_MODE: "driving", @@ -69,53 +143,20 @@ class GoogleOptionsFlow(config_entries.OptionsFlow): user_input[CONF_DEPARTURE_TIME] = time return self.async_create_entry( title="", - data={k: v for k, v in user_input.items() if v not in (None, "")}, + data=user_input, ) + options = self.config_entry.options.copy() if CONF_ARRIVAL_TIME in self.config_entry.options: - default_time_type = ARRIVAL_TIME - default_time = self.config_entry.options[CONF_ARRIVAL_TIME] + options[CONF_TIME_TYPE] = ARRIVAL_TIME + options[CONF_TIME] = self.config_entry.options[CONF_ARRIVAL_TIME] else: - default_time_type = DEPARTURE_TIME - default_time = self.config_entry.options.get(CONF_DEPARTURE_TIME, "") + options[CONF_TIME_TYPE] = DEPARTURE_TIME + options[CONF_TIME] = self.config_entry.options.get(CONF_DEPARTURE_TIME, "") return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_MODE, default=self.config_entry.options[CONF_MODE] - ): vol.In(TRAVEL_MODE), - vol.Optional( - CONF_LANGUAGE, - default=self.config_entry.options.get(CONF_LANGUAGE), - ): vol.In([None, *ALL_LANGUAGES]), - vol.Optional( - CONF_AVOID, default=self.config_entry.options.get(CONF_AVOID) - ): vol.In([None, *AVOID]), - vol.Optional( - CONF_UNITS, default=self.config_entry.options[CONF_UNITS] - ): vol.In(UNITS), - vol.Optional(CONF_TIME_TYPE, default=default_time_type): vol.In( - TIME_TYPES - ), - vol.Optional(CONF_TIME, default=default_time): cv.string, - vol.Optional( - CONF_TRAFFIC_MODEL, - default=self.config_entry.options.get(CONF_TRAFFIC_MODEL), - ): vol.In([None, *TRAVEL_MODEL]), - vol.Optional( - CONF_TRANSIT_MODE, - default=self.config_entry.options.get(CONF_TRANSIT_MODE), - ): vol.In([None, *TRANSPORT_TYPE]), - vol.Optional( - CONF_TRANSIT_ROUTING_PREFERENCE, - default=self.config_entry.options.get( - CONF_TRANSIT_ROUTING_PREFERENCE - ), - ): vol.In([None, *TRANSIT_PREFS]), - } - ), + data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, options), ) diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index efc17b22ec1..0535e295b93 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -77,11 +77,11 @@ ALL_LANGUAGES = [ "zh-TW", ] -AVOID = ["tolls", "highways", "ferries", "indoor"] +AVOID_OPTIONS = ["tolls", "highways", "ferries", "indoor"] TRANSIT_PREFS = ["less_walking", "fewer_transfers"] -TRANSPORT_TYPE = ["bus", "subway", "train", "tram", "rail"] -TRAVEL_MODE = ["driving", "walking", "bicycling", "transit"] -TRAVEL_MODEL = ["best_guess", "pessimistic", "optimistic"] +TRANSPORT_TYPES = ["bus", "subway", "train", "tram", "rail"] +TRAVEL_MODES = ["driving", "walking", "bicycling", "transit"] +TRAFFIC_MODELS = ["best_guess", "pessimistic", "optimistic"] # googlemaps library uses "metric" or "imperial" terminology in distance_matrix UNITS_METRIC = "metric" diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 270f8fe31e2..e3a13a3d2e3 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -30,12 +30,65 @@ "time_type": "Time Type", "time": "Time", "avoid": "Avoid", - "traffic_mode": "Traffic Mode", + "traffic_model": "Traffic Model", "transit_mode": "Transit Mode", "transit_routing_preference": "Transit Routing Preference", "units": "Units" } } } + }, + "selector": { + "mode": { + "options": { + "driving": "Driving", + "walking": "Walking", + "bicycling": "Bicycling", + "transit": "Transit" + } + }, + "avoid": { + "options": { + "none": "Avoid nothing", + "tolls": "Tolls", + "highways": "Highways", + "ferries": "Ferries", + "indoor": "Indoor" + } + }, + "units": { + "options": { + "metric": "Metric System", + "imperial": "Imperial System" + } + }, + "time_type": { + "options": { + "arrival_time": "Arrival Time", + "departure_time": "Departure Time" + } + }, + "traffic_model": { + "options": { + "best_guess": "Best Guess", + "pessimistic": "Pessimistic", + "optimistic": "Optimistic" + } + }, + "transit_mode": { + "options": { + "bus": "Bus", + "subway": "Subway", + "train": "Train", + "tram": "Tram", + "rail": "Rail" + } + }, + "transit_routing_preference": { + "options": { + "less_walking": "Less Walking", + "fewer_transfers": "Fewer Transfers" + } + } } } diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index b8fea6a07e1..5c47f116ce5 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -85,5 +85,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.23.0"] + "requirements": ["govee-ble==0.24.0"] } diff --git a/homeassistant/components/govee_ble/strings.json b/homeassistant/components/govee_ble/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/govee_ble/strings.json +++ b/homeassistant/components/govee_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 364ef15fa5e..82c2651e764 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -526,12 +526,13 @@ class GroupEntity(Entity): self.hass, self._entity_ids, async_state_changed_listener ) ) + self.async_on_remove(start.async_at_start(self.hass, self._update_at_start)) - async def _update_at_start(_: HomeAssistant) -> None: - self.async_update_group_state() - self.async_write_ha_state() - - self.async_on_remove(start.async_at_start(self.hass, _update_at_start)) + @callback + def _update_at_start(self, _: HomeAssistant) -> None: + """Update the group state at start.""" + self.async_update_group_state() + self.async_write_ha_state() @callback def async_defer_or_update_ha_state(self) -> None: diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index e5ac921cc77..ceed4bb7b42 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -18,15 +18,18 @@ set: entities: example: domain.entity_id1, domain.entity_id2 selector: - object: + entity: + multiple: true add_entities: example: domain.entity_id1, domain.entity_id2 selector: - object: + entity: + multiple: true remove_entities: example: domain.entity_id1, domain.entity_id2 selector: - object: + entity: + multiple: true all: selector: boolean: diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 5f3042c5bf7..c5cebbc4707 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -32,7 +32,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "event": { @@ -40,7 +40,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "fan": { @@ -48,7 +48,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "light": { @@ -56,7 +56,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "lock": { @@ -64,7 +64,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "media_player": { @@ -72,7 +72,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "sensor": { @@ -94,7 +94,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } } } @@ -145,8 +145,8 @@ "description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.", "data": { "ignore_non_numeric": "[%key:component::group::config::step::sensor::data::ignore_non_numeric%]", - "entities": "[%key:component::group::config::step::sensor::data::entities%]", - "hide_members": "[%key:component::group::config::step::sensor::data::hide_members%]", + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", "type": "[%key:component::group::config::step::sensor::data::type%]", "round_digits": "[%key:component::group::config::step::sensor::data::round_digits%]", "device_class": "[%key:component::group::config::step::sensor::data::device_class%]", @@ -170,8 +170,8 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "home": "[%key:component::device_tracker::entity_component::_::state::home%]", - "not_home": "[%key:component::device_tracker::entity_component::_::state::not_home%]", + "home": "[%key:common::state::home%]", + "not_home": "[%key:common::state::not_home%]", "open": "[%key:common::state::open%]", "closed": "[%key:common::state::closed%]", "locked": "[%key:common::state::locked%]", diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 75b2535bd44..e7ab7aac3c8 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections import defaultdict from contextlib import suppress from datetime import datetime, timedelta import logging @@ -12,24 +13,18 @@ from typing import Any, NamedTuple import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.components import panel_custom, persistent_notification -from homeassistant.components.homeassistant import ( - SERVICE_CHECK_CONFIG, - SHUTDOWN_SERVICES, -) -import homeassistant.config as conf_util +from homeassistant.components import panel_custom +from homeassistant.components.homeassistant import async_set_stop_handler from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MANUFACTURER, ATTR_NAME, EVENT_CORE_CONFIG_UPDATE, HASSIO_USER_NAME, - SERVICE_HOMEASSISTANT_RESTART, - SERVICE_HOMEASSISTANT_STOP, Platform, ) from homeassistant.core import ( - DOMAIN as HASS_DOMAIN, + CALLBACK_TYPE, HassJob, HomeAssistant, ServiceCall, @@ -37,12 +32,9 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - recorder, -) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_call_later from homeassistant.helpers.storage import Store @@ -62,6 +54,7 @@ from .const import ( ATTR_COMPRESSED, ATTR_FOLDERS, ATTR_HOMEASSISTANT, + ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, ATTR_INPUT, ATTR_LOCATION, ATTR_PASSWORD, @@ -71,6 +64,10 @@ from .const import ( ATTR_STATE, ATTR_URL, ATTR_VERSION, + CONTAINER_CHANGELOG, + CONTAINER_INFO, + CONTAINER_STATS, + CORE_CONTAINER, DATA_KEY_ADDONS, DATA_KEY_CORE, DATA_KEY_HOST, @@ -78,6 +75,8 @@ from .const import ( DATA_KEY_SUPERVISOR, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, + REQUEST_REFRESH_DELAY, + SUPERVISOR_CONTAINER, SupervisorEntityModel, ) from .discovery import HassioServiceInfo, async_setup_discovery_view # noqa: F401 @@ -186,6 +185,7 @@ SCHEMA_BACKUP_FULL = vol.Schema( vol.Optional(ATTR_LOCATION): vol.All( cv.string, lambda v: None if v == "/backup" else v ), + vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean, } ) @@ -336,7 +336,7 @@ def get_addons_stats(hass): Async friendly. """ - return hass.data.get(DATA_ADDONS_STATS) + return hass.data.get(DATA_ADDONS_STATS) or {} @callback @@ -346,7 +346,7 @@ def get_core_stats(hass): Async friendly. """ - return hass.data.get(DATA_CORE_STATS) + return hass.data.get(DATA_CORE_STATS) or {} @callback @@ -356,7 +356,7 @@ def get_supervisor_stats(hass): Async friendly. """ - return hass.data.get(DATA_SUPERVISOR_STATS) + return hass.data.get(DATA_SUPERVISOR_STATS) or {} @callback @@ -564,53 +564,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # Fetch data await update_info_data() - async def async_handle_core_service(call: ServiceCall) -> None: - """Service handler for handling core services.""" - if call.service in SHUTDOWN_SERVICES and recorder.async_migration_in_progress( - hass - ): - _LOGGER.error( - "The system cannot %s while a database upgrade is in progress", - call.service, - ) - raise HomeAssistantError( - f"The system cannot {call.service} " - "while a database upgrade is in progress." - ) - - if call.service == SERVICE_HOMEASSISTANT_STOP: - await hassio.stop_homeassistant() - return - - errors = await conf_util.async_check_ha_config_file(hass) - - if errors: - _LOGGER.error( - "The system cannot %s because the configuration is not valid: %s", - call.service, - errors, - ) - persistent_notification.async_create( - hass, - "Config error. See [the logs](/config/logs) for details.", - "Config validating", - f"{HASS_DOMAIN}.check_config", - ) - raise HomeAssistantError( - f"The system cannot {call.service} " - f"because the configuration is not valid: {errors}" - ) - - if call.service == SERVICE_HOMEASSISTANT_RESTART: + async def _async_stop(hass: HomeAssistant, restart: bool) -> None: + """Stop or restart home assistant.""" + if restart: await hassio.restart_homeassistant() + else: + await hassio.stop_homeassistant() - # Mock core services - for service in ( - SERVICE_HOMEASSISTANT_STOP, - SERVICE_HOMEASSISTANT_RESTART, - SERVICE_CHECK_CONFIG, - ): - hass.services.async_register(HASS_DOMAIN, service, async_handle_core_service) + # Set a custom handler for the homeassistant.restart and homeassistant.stop services + async_set_stop_handler(hass, _async_stop) # Init discovery Hass.io feature async_setup_discovery_view(hass, hassio) @@ -794,17 +756,28 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER, name=DOMAIN, update_interval=HASSIO_UPDATE_INTERVAL, + # We don't want an immediate refresh since we want to avoid + # fetching the container stats right away and avoid hammering + # the Supervisor API on startup + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), ) self.hassio: HassIO = hass.data[DOMAIN] self.data = {} self.entry_id = config_entry.entry_id self.dev_reg = dev_reg self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None + self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict( + lambda: defaultdict(set) + ) async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" + is_first_update = not self.data + try: - await self.force_data_refresh() + await self.force_data_refresh(is_first_update) except HassioAPIError as err: raise UpdateFailed(f"Error on Supervisor API: {err}") from err @@ -848,7 +821,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {} # If this is the initial refresh, register all addons and return the dict - if not self.data: + if is_first_update: async_register_addons_in_dev_reg( self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values() ) @@ -898,47 +871,84 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.hass.data[DATA_SUPERVISOR_INFO] = await self.hassio.get_supervisor_info() await self.async_refresh() - async def force_data_refresh(self) -> None: + async def force_data_refresh(self, first_update: bool) -> None: """Force update of the addon info.""" - ( - self.hass.data[DATA_INFO], - self.hass.data[DATA_CORE_INFO], - self.hass.data[DATA_CORE_STATS], - self.hass.data[DATA_SUPERVISOR_INFO], - self.hass.data[DATA_SUPERVISOR_STATS], - self.hass.data[DATA_OS_INFO], - ) = await asyncio.gather( - self.hassio.get_info(), - self.hassio.get_core_info(), - self.hassio.get_core_stats(), - self.hassio.get_supervisor_info(), - self.hassio.get_supervisor_stats(), - self.hassio.get_os_info(), - ) + container_updates = self._container_updates - all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - started_addons = [ - addon for addon in all_addons if addon[ATTR_STATE] == ATTR_STARTED - ] - stats_data = await asyncio.gather( - *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in started_addons] - ) - self.hass.data[DATA_ADDONS_STATS] = dict(stats_data) - self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( - await asyncio.gather( - *[ - self._update_addon_changelog(addon[ATTR_SLUG]) - for addon in all_addons - ] - ) - ) - self.hass.data[DATA_ADDONS_INFO] = dict( - await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] - ) - ) + data = self.hass.data + hassio = self.hassio + updates = { + DATA_INFO: hassio.get_info(), + DATA_CORE_INFO: hassio.get_core_info(), + DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(), + DATA_OS_INFO: hassio.get_os_info(), + } + if CONTAINER_STATS in container_updates[CORE_CONTAINER]: + updates[DATA_CORE_STATS] = hassio.get_core_stats() + if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: + updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats() - async def _update_addon_stats(self, slug): + results = await asyncio.gather(*updates.values()) + for key, result in zip(updates, results): + data[key] = result + + _addon_data = data[DATA_SUPERVISOR_INFO].get("addons", []) + all_addons: list[str] = [] + started_addons: list[str] = [] + for addon in _addon_data: + slug = addon[ATTR_SLUG] + all_addons.append(slug) + if addon[ATTR_STATE] == ATTR_STARTED: + started_addons.append(slug) + # + # Update add-on info if its the first update or + # there is at least one entity that needs the data. + # + # When entities are added they call async_enable_container_updates + # to enable updates for the endpoints they need via + # async_added_to_hass. This ensures that we only update + # the data for the endpoints that are needed to avoid unnecessary + # API calls since otherwise we would fetch stats for all containers + # and throw them away. + # + for data_key, update_func, enabled_key, wanted_addons, needs_first_update in ( + ( + DATA_ADDONS_STATS, + self._update_addon_stats, + CONTAINER_STATS, + started_addons, + False, + ), + ( + DATA_ADDONS_CHANGELOGS, + self._update_addon_changelog, + CONTAINER_CHANGELOG, + all_addons, + True, + ), + ( + DATA_ADDONS_INFO, + self._update_addon_info, + CONTAINER_INFO, + all_addons, + True, + ), + ): + container_data: dict[str, Any] = data.setdefault(data_key, {}) + container_data.update( + dict( + await asyncio.gather( + *[ + update_func(slug) + for slug in wanted_addons + if (first_update and needs_first_update) + or enabled_key in container_updates[slug] + ] + ) + ) + ) + + async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]: """Update single addon stats.""" try: stats = await self.hassio.get_addon_stats(slug) @@ -947,7 +957,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER.warning("Could not fetch stats for %s: %s", slug, err) return (slug, None) - async def _update_addon_changelog(self, slug): + async def _update_addon_changelog(self, slug: str) -> tuple[str, str | None]: """Return the changelog for an add-on.""" try: changelog = await self.hassio.get_addon_changelog(slug) @@ -956,7 +966,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER.warning("Could not fetch changelog for %s: %s", slug, err) return (slug, None) - async def _update_addon_info(self, slug): + async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]: """Return the info for an add-on.""" try: info = await self.hassio.get_addon_info(slug) @@ -965,6 +975,22 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER.warning("Could not fetch info for %s: %s", slug, err) return (slug, None) + @callback + def async_enable_container_updates( + self, slug: str, entity_id: str, types: set[str] + ) -> CALLBACK_TYPE: + """Enable updates for an add-on.""" + enabled_updates = self._container_updates[slug] + for key in types: + enabled_updates[key].add(entity_id) + + @callback + def _remove(): + for key in types: + enabled_updates[key].remove(entity_id) + + return _remove + async def _async_refresh( self, log_failures: bool = True, diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 5712f5d1bea..b495745e87d 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -16,6 +16,7 @@ ATTR_ENDPOINT = "endpoint" ATTR_FOLDERS = "folders" ATTR_HEALTHY = "healthy" ATTR_HOMEASSISTANT = "homeassistant" +ATTR_HOMEASSISTANT_EXCLUDE_DATABASE = "homeassistant_exclude_database" ATTR_INPUT = "input" ATTR_ISSUES = "issues" ATTR_METHOD = "method" @@ -82,6 +83,26 @@ PLACEHOLDER_KEY_COMPONENTS = "components" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" +CORE_CONTAINER = "homeassistant" +SUPERVISOR_CONTAINER = "hassio_supervisor" + +CONTAINER_STATS = "stats" +CONTAINER_CHANGELOG = "changelog" +CONTAINER_INFO = "info" + +# This is a mapping of which endpoint the key in the addon data +# is obtained from so we know which endpoint to update when the +# coordinator polls for updates. +KEY_TO_UPDATE_TYPES: dict[str, set[str]] = { + ATTR_VERSION_LATEST: {CONTAINER_INFO, CONTAINER_CHANGELOG}, + ATTR_MEMORY_PERCENT: {CONTAINER_STATS}, + ATTR_CPU_PERCENT: {CONTAINER_STATS}, + ATTR_VERSION: {CONTAINER_INFO}, + ATTR_STATE: {CONTAINER_INFO}, +} + +REQUEST_REFRESH_DELAY = 10 + class SupervisorEntityModel(StrEnum): """Supervisor entity model.""" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 6530aba3ea1..63e0314dd05 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -10,11 +10,15 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, HassioDataUpdateCoordinator from .const import ( ATTR_SLUG, + CONTAINER_STATS, + CORE_CONTAINER, DATA_KEY_ADDONS, DATA_KEY_CORE, DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, + KEY_TO_UPDATE_TYPES, + SUPERVISOR_CONTAINER, ) @@ -46,6 +50,18 @@ class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): in self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) ) + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] + self.async_on_remove( + self.coordinator.async_enable_container_updates( + self._addon_slug, self.entity_id, update_types + ) + ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() + class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Base Entity for Hass.io OS.""" @@ -125,6 +141,18 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): in self.coordinator.data[DATA_KEY_SUPERVISOR] ) + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] + self.async_on_remove( + self.coordinator.async_enable_container_updates( + SUPERVISOR_CONTAINER, self.entity_id, update_types + ) + ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() + class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Base Entity for Core.""" @@ -150,3 +178,15 @@ class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): and DATA_KEY_CORE in self.coordinator.data and self.entity_description.key in self.coordinator.data[DATA_KEY_CORE] ) + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] + self.async_on_remove( + self.coordinator.async_enable_container_updates( + CORE_CONTAINER, self.entity_id, update_types + ) + ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 5bcdb6896cd..419d80484cf 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -156,16 +156,15 @@ class HassIOView(HomeAssistantView): # _stored_content_type is only computed once `content_type` is accessed if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary - headers[ - CONTENT_TYPE - ] = request._stored_content_type # pylint: disable=protected-access + # pylint: disable-next=protected-access + headers[CONTENT_TYPE] = request._stored_content_type try: client = await self._websession.request( method=request.method, url=f"http://{self._host}/{quote(path)}", params=request.query, - data=request.content, + data=request.content if request.method != "GET" else None, headers=headers, timeout=_get_timeout(path), ) @@ -179,7 +178,10 @@ class HassIOView(HomeAssistantView): if should_compress(response.content_type): response.enable_compression() await response.prepare(request) - async for data in client.content.iter_chunked(8192): + # In testing iter_chunked, iter_any, and iter_chunks: + # iter_chunks was the best performing option since + # it does not have to do as much re-assembly + async for data, _ in client.content.iter_chunks(): await response.write(data) return response diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 4a612de7f87..b8c5873b967 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -162,7 +162,7 @@ class HassIOIngress(HomeAssistantView): headers=source_header, params=request.query, allow_redirects=False, - data=request.content, + data=request.content if request.method != "GET" else None, timeout=ClientTimeout(total=None), skip_auto_headers={hdrs.CONTENT_TYPE}, ) as result: @@ -198,7 +198,10 @@ class HassIOIngress(HomeAssistantView): if should_compress(response.content_type): response.enable_compression() await response.prepare(request) - async for data in result.content.iter_chunked(8192): + # In testing iter_chunked, iter_any, and iter_chunks: + # iter_chunks was the best performing option since + # it does not have to do as much re-assembly + async for data, _ in result.content.iter_chunks(): await response.write(data) except ( diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index d5e26d4670f..8337405641c 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -86,15 +86,21 @@ class SupervisorIssueRepairFlow(RepairsFlow): ) 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, - ) + return await self.async_step_fix_menu() # 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_fix_menu(self, _: None = None) -> FlowResult: + """Show the fix menu.""" + assert self.issue + + return self.async_show_menu( + step_id="fix_menu", + menu_options=[suggestion.key for suggestion in self.issue.suggestions], + description_placeholders=self.description_placeholders, + ) + async def _async_step_apply_suggestion( self, suggestion: Suggestion, confirmed: bool = False ) -> FlowResult: diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 33eb1e88ed3..30086e4dd2b 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -58,12 +58,20 @@ backup_full: example: my_backup_mount selector: backup_location: + homeassistant_exclude_database: + default: false + selector: + boolean: backup_partial: fields: homeassistant: selector: boolean: + homeassistant_exclude_database: + default: false + selector: + boolean: addons: example: ["core_ssh", "core_samba", "core_mosquitto"] selector: diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index c45d455631b..77ef408cafe 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -296,6 +296,10 @@ "location": { "name": "[%key:common::config_flow::data::location%]", "description": "Name of a backup network storage to host backups." + }, + "homeassistant_exclude_database": { + "name": "Home Assistant exclude database", + "description": "Exclude the Home Assistant database file from backup" } } }, @@ -316,11 +320,11 @@ "description": "List of directories to include in the backup." }, "name": { - "name": "[%key:component::hassio::services::backup_full::fields::name::name%]", + "name": "[%key:common::config_flow::data::name%]", "description": "[%key:component::hassio::services::backup_full::fields::name::description%]" }, "password": { - "name": "[%key:component::hassio::services::backup_full::fields::password::name%]", + "name": "[%key:common::config_flow::data::password%]", "description": "[%key:component::hassio::services::backup_full::fields::password::description%]" }, "compressed": { @@ -328,8 +332,12 @@ "description": "[%key:component::hassio::services::backup_full::fields::compressed::description%]" }, "location": { - "name": "[%key:component::hassio::services::backup_full::fields::location::name%]", + "name": "[%key:common::config_flow::data::location%]", "description": "[%key:component::hassio::services::backup_full::fields::location::description%]" + }, + "homeassistant_exclude_database": { + "name": "Home Assistant exclude database", + "description": "Exclude the Home Assistant database file from backup" } } }, @@ -342,7 +350,7 @@ "description": "Slug of backup to restore from." }, "password": { - "name": "[%key:component::hassio::services::backup_full::fields::password::name%]", + "name": "[%key:common::config_flow::data::password%]", "description": "Optional password." } } @@ -368,7 +376,7 @@ "description": "[%key:component::hassio::services::backup_partial::fields::addons::description%]" }, "password": { - "name": "[%key:component::hassio::services::backup_full::fields::password::name%]", + "name": "[%key:common::config_flow::data::password%]", "description": "[%key:component::hassio::services::restore_full::fields::password::description%]" } } diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 285a2663d92..8a3199a1121 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -181,17 +181,17 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): @property def latest_version(self) -> str: - """Return native value of entity.""" + """Return the latest version.""" return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION_LATEST] @property def installed_version(self) -> str: - """Return native value of entity.""" + """Return the installed version.""" return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION] @property def entity_picture(self) -> str | None: - """Return the iconof the entity.""" + """Return the icon of the entity.""" return "https://brands.home-assistant.io/homeassistant/icon.png" @property @@ -224,12 +224,12 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): @property def latest_version(self) -> str: - """Return native value of entity.""" + """Return the latest version.""" return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION_LATEST] @property def installed_version(self) -> str: - """Return native value of entity.""" + """Return the installed version.""" return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION] @property @@ -247,7 +247,7 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): @property def entity_picture(self) -> str | None: - """Return the iconof the entity.""" + """Return the icon of the entity.""" return "https://brands.home-assistant.io/hassio/icon.png" async def async_install( @@ -274,17 +274,17 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): @property def latest_version(self) -> str: - """Return native value of entity.""" + """Return the latest version.""" return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION_LATEST] @property def installed_version(self) -> str: - """Return native value of entity.""" + """Return the installed version.""" return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION] @property def entity_picture(self) -> str | None: - """Return the iconof the entity.""" + """Return the icon of the entity.""" return "https://brands.home-assistant.io/homeassistant/icon.png" @property diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 48a500f17f0..3db4a841d53 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -124,14 +124,18 @@ class HERETravelTimeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" if not errors: self._config = user_input - return self.async_show_menu( - step_id="origin_menu", - menu_options=["origin_coordinates", "origin_entity"], - ) + return await self.async_step_origin_menu() return self.async_show_form( step_id="user", data_schema=get_user_step_schema(user_input), errors=errors ) + async def async_step_origin_menu(self, _: None = None) -> FlowResult: + """Show the origin menu.""" + return self.async_show_menu( + step_id="origin_menu", + menu_options=["origin_coordinates", "origin_entity"], + ) + async def async_step_origin_coordinates( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -141,10 +145,7 @@ class HERETravelTimeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._config[CONF_ORIGIN_LONGITUDE] = user_input[CONF_ORIGIN][ CONF_LONGITUDE ] - return self.async_show_menu( - step_id="destination_menu", - menu_options=["destination_coordinates", "destination_entity"], - ) + return await self.async_step_destination_menu() schema = vol.Schema( { vol.Required( @@ -158,16 +159,20 @@ class HERETravelTimeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="origin_coordinates", data_schema=schema) + async def async_step_destination_menu(self, _: None = None) -> FlowResult: + """Show the destination menu.""" + return self.async_show_menu( + step_id="destination_menu", + menu_options=["destination_coordinates", "destination_entity"], + ) + async def async_step_origin_entity( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Configure origin by using an entity.""" if user_input is not None: self._config[CONF_ORIGIN_ENTITY_ID] = user_input[CONF_ORIGIN_ENTITY_ID] - return self.async_show_menu( - step_id="destination_menu", - menu_options=["destination_coordinates", "destination_entity"], - ) + return await self.async_step_destination_menu() schema = vol.Schema({vol.Required(CONF_ORIGIN_ENTITY_ID): EntitySelector()}) return self.async_show_form(step_id="origin_entity", data_schema=schema) @@ -237,10 +242,7 @@ class HERETravelTimeOptionsFlow(config_entries.OptionsFlow): """Manage the HERE Travel Time options.""" if user_input is not None: self._config = user_input - return self.async_show_menu( - step_id="time_menu", - menu_options=["departure_time", "arrival_time", "no_time"], - ) + return await self.async_step_time_menu() schema = vol.Schema( { @@ -255,6 +257,13 @@ class HERETravelTimeOptionsFlow(config_entries.OptionsFlow): return self.async_show_form(step_id="init", data_schema=schema) + async def async_step_time_menu(self, _: None = None) -> FlowResult: + """Show the time menu.""" + return self.async_show_menu( + step_id="time_menu", + menu_options=["departure_time", "arrival_time", "no_time"], + ) + async def async_step_no_time( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index e4032ad954d..c978a7d4320 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -1,7 +1,9 @@ """Integration providing core pieces of infrastructure.""" import asyncio +from collections.abc import Callable, Coroutine import itertools as it import logging +from typing import Any import voluptuous as vol @@ -14,8 +16,6 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, RESTART_EXIT_CODE, - SERVICE_HOMEASSISTANT_RESTART, - SERVICE_HOMEASSISTANT_STOP, SERVICE_RELOAD, SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, @@ -34,10 +34,17 @@ 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 .const import ( + DATA_EXPOSED_ENTITIES, + DATA_STOP_HANDLER, + DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, + SERVICE_HOMEASSISTANT_STOP, +) from .exposed_entities import ExposedEntities ATTR_ENTRY_ID = "entry_id" +ATTR_SAFE_MODE = "safe_mode" _LOGGER = logging.getLogger(__name__) SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" @@ -57,7 +64,7 @@ SCHEMA_RELOAD_CONFIG_ENTRY = vol.All( ), cv.has_at_least_one_key(ATTR_ENTRY_ID, *cv.ENTITY_SERVICE_FIELDS), ) - +SCHEMA_RESTART = vol.Schema({vol.Optional(ATTR_SAFE_MODE, default=False): bool}) SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) @@ -148,6 +155,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no async def async_handle_core_service(call: ha.ServiceCall) -> None: """Service handler for handling core services.""" + stop_handler: Callable[[ha.HomeAssistant, bool], Coroutine[Any, Any, None]] + if call.service in SHUTDOWN_SERVICES and recorder.async_migration_in_progress( hass ): @@ -161,8 +170,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no ) if call.service == SERVICE_HOMEASSISTANT_STOP: - # Track trask in hass.data. No need to cleanup, we're stopping. - hass.data["homeassistant_stop"] = asyncio.create_task(hass.async_stop()) + stop_handler = hass.data[DATA_STOP_HANDLER] + await stop_handler(hass, False) return errors = await conf_util.async_check_ha_config_file(hass) @@ -185,10 +194,10 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no ) if call.service == SERVICE_HOMEASSISTANT_RESTART: - # Track trask in hass.data. No need to cleanup, we're stopping. - hass.data["homeassistant_stop"] = asyncio.create_task( - hass.async_stop(RESTART_EXIT_CODE) - ) + if call.data[ATTR_SAFE_MODE]: + await conf_util.async_enable_safe_mode(hass) + stop_handler = hass.data[DATA_STOP_HANDLER] + await stop_handler(hass, True) async def async_handle_update_service(call: ha.ServiceCall) -> None: """Service handler for updating an entity.""" @@ -222,7 +231,11 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass, ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service ) async_register_admin_service( - hass, ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service + hass, + ha.DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, + async_handle_core_service, + SCHEMA_RESTART, ) async_register_admin_service( hass, ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service @@ -358,5 +371,22 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no exposed_entities = ExposedEntities(hass) await exposed_entities.async_initialize() hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities + async_set_stop_handler(hass, _async_stop) return True + + +async def _async_stop(hass: ha.HomeAssistant, restart: bool): + """Stop home assistant.""" + exit_code = RESTART_EXIT_CODE if restart else 0 + # Track trask in hass.data. No need to cleanup, we're stopping. + hass.data["homeassistant_stop"] = asyncio.create_task(hass.async_stop(exit_code)) + + +@ha.callback +def async_set_stop_handler( + hass: ha.HomeAssistant, + stop_handler: Callable[[ha.HomeAssistant, bool], Coroutine[Any, Any, None]], +) -> None: + """Set function which is called by the stop and restart services.""" + hass.data[DATA_STOP_HANDLER] = stop_handler diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py index f3bc95dd1ee..871ea5a0371 100644 --- a/homeassistant/components/homeassistant/const.py +++ b/homeassistant/components/homeassistant/const.py @@ -1,6 +1,12 @@ """Constants for the Homeassistant integration.""" +from typing import Final + import homeassistant.core as ha DOMAIN = ha.DOMAIN DATA_EXPOSED_ENTITIES = f"{DOMAIN}.exposed_entites" +DATA_STOP_HANDLER = f"{DOMAIN}.stop_handler" + +SERVICE_HOMEASSISTANT_STOP: Final = "stop" +SERVICE_HOMEASSISTANT_RESTART: Final = "restart" diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index a3435a8d1f5..26871522819 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -68,7 +68,13 @@ }, "restart": { "name": "[%key:common::action::restart%]", - "description": "Restarts Home Assistant." + "description": "Restarts Home Assistant.", + "fields": { + "safe_mode": { + "name": "Safe mode", + "description": "Disable custom integrations and custom cards." + } + } }, "set_location": { "name": "Set location", diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 40cf1e18b0e..7884d3f5617 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -588,9 +588,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): serial_device = (await self._async_serial_port_settings()).device if addon_info.options.get(CONF_ADDON_DEVICE) != serial_device: return await self.async_step_addon_installed_other_device() - return await self.async_step_show_addon_menu() + return await self.async_step_addon_menu() - async def async_step_show_addon_menu( + async def async_step_addon_menu( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Show menu options for the addon.""" diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 2ed0026a48c..825649ef0d3 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -13,12 +13,12 @@ }, "addon_menu": { "menu_options": { - "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", - "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "change_channel": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::title%]", + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", "data": { "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]" }, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 667b8f3d97a..7681d6d3847 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -63,6 +63,10 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle logic when on Supervisor host.""" + return await self.async_step_main_menu() + + async def async_step_main_menu(self, _: None = None) -> FlowResult: + """Show the main menu.""" return self.async_show_menu( step_id="main_menu", menu_options=[ @@ -85,7 +89,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl 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() + return await self.async_step_reboot_menu() try: async with asyncio.timeout(10): @@ -102,7 +106,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl return self.async_show_form(step_id="hardware_settings", data_schema=schema) - async def async_step_confirm_reboot( + async def async_step_reboot_menu( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm reboot host.""" diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 894d799d073..95442d31500 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -13,12 +13,12 @@ }, "addon_menu": { "menu_options": { - "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", - "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "change_channel": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::title%]", + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", "data": { "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]" }, diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index bb4efb7db6c..0920530524d 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -47,7 +47,14 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State, callback +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + HomeAssistant, + ServiceCall, + State, + callback, +) from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import ( config_validation as cv, @@ -55,6 +62,7 @@ from homeassistant.helpers import ( entity_registry as er, instance_id, ) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entityfilter import ( BASE_FILTER_SCHEMA, FILTER_SCHEMA, @@ -104,18 +112,16 @@ from .const import ( DEFAULT_HOMEKIT_MODE, DEFAULT_PORT, DOMAIN, - HOMEKIT, HOMEKIT_MODE_ACCESSORY, HOMEKIT_MODES, - HOMEKIT_PAIRING_QR, - HOMEKIT_PAIRING_QR_SECRET, MANUFACTURER, - PERSIST_LOCK, + PERSIST_LOCK_DATA, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) from .iidmanager import AccessoryIIDStorage +from .models import HomeKitEntryData from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, @@ -197,11 +203,8 @@ UNPAIR_SERVICE_SCHEMA = vol.All( def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]: """All active HomeKit instances.""" - return [ - data[HOMEKIT] - for data in hass.data[DOMAIN].values() - if isinstance(data, dict) and HOMEKIT in data - ] + domain_data: dict[str, HomeKitEntryData] = hass.data[DOMAIN] + return [data.homekit for data in domain_data.values()] def _async_get_imported_entries_indices( @@ -223,7 +226,8 @@ def _async_get_imported_entries_indices( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HomeKit from yaml.""" - hass.data.setdefault(DOMAIN, {})[PERSIST_LOCK] = asyncio.Lock() + hass.data[DOMAIN] = {} + hass.data[PERSIST_LOCK_DATA] = asyncio.Lock() # Initialize the loader before loading entries to ensure # there is no race where multiple entries try to load it @@ -344,7 +348,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, homekit.async_stop) ) - hass.data[DOMAIN][entry.entry_id] = {HOMEKIT: homekit} + entry_data = HomeKitEntryData( + homekit=homekit, pairing_qr=None, pairing_qr_secret=None + ) + hass.data[DOMAIN][entry.entry_id] = entry_data if hass.state == CoreState.running: await homekit.async_start() @@ -364,7 +371,8 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" async_dismiss_setup_message(hass, entry.entry_id) - homekit = hass.data[DOMAIN][entry.entry_id][HOMEKIT] + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry.entry_id] + homekit = entry_data.homekit if homekit.status == STATUS_RUNNING: await homekit.async_stop() @@ -534,6 +542,7 @@ class HomeKit: self.driver: HomeDriver | None = None self.bridge: HomeBridge | None = None self._reset_lock = asyncio.Lock() + self._cancel_reload_dispatcher: CALLBACK_TYPE | None = None def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None: """Set up bridge and accessory driver.""" @@ -563,16 +572,28 @@ class HomeKit: async def async_reset_accessories(self, entity_ids: Iterable[str]) -> None: """Reset the accessory to load the latest configuration.""" + _LOGGER.debug("Resetting accessories: %s", entity_ids) async with self._reset_lock: if not self.bridge: - await self.async_reset_accessories_in_accessory_mode(entity_ids) + # For accessory mode reset and reload are the same + await self._async_reload_accessories_in_accessory_mode(entity_ids) return - await self.async_reset_accessories_in_bridge_mode(entity_ids) + await self._async_reset_accessories_in_bridge_mode(entity_ids) - async def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None: + async def async_reload_accessories(self, entity_ids: Iterable[str]) -> None: + """Reload the accessory to load the latest configuration.""" + _LOGGER.debug("Reloading accessories: %s", entity_ids) + async with self._reset_lock: + if not self.bridge: + await self._async_reload_accessories_in_accessory_mode(entity_ids) + return + await self._async_reload_accessories_in_bridge_mode(entity_ids) + + @callback + def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None: """Shutdown an accessory.""" assert self.driver is not None - await accessory.stop() + accessory.async_stop() # Deallocate the IIDs for the accessory iid_manager = accessory.iid_manager services: list[Service] = accessory.services @@ -582,7 +603,7 @@ class HomeKit: for char in characteristics: iid_manager.remove_obj(char) - async def async_reset_accessories_in_accessory_mode( + async def _async_reload_accessories_in_accessory_mode( self, entity_ids: Iterable[str] ) -> None: """Reset accessories in accessory mode.""" @@ -593,63 +614,88 @@ class HomeKit: return if not (state := self.hass.states.get(acc.entity_id)): _LOGGER.warning( - "The underlying entity %s disappeared during reset", acc.entity_id + "The underlying entity %s disappeared during reload", acc.entity_id ) return - await self._async_shutdown_accessory(acc) + self._async_shutdown_accessory(acc) if new_acc := self._async_create_single_accessory([state]): self.driver.accessory = new_acc - self.hass.async_create_task( - new_acc.run(), f"HomeKit Bridge Accessory: {new_acc.entity_id}" - ) - await self.async_config_changed() + # Run must be awaited here since it may change + # the accessories hash + await new_acc.run() + self._async_update_accessories_hash() - async def async_reset_accessories_in_bridge_mode( + def _async_remove_accessories_by_entity_id( self, entity_ids: Iterable[str] - ) -> None: - """Reset accessories in bridge mode.""" + ) -> list[str]: + """Remove accessories by entity id.""" assert self.aid_storage is not None assert self.bridge is not None - assert self.driver is not None - - new = [] + removed: list[str] = [] acc: HomeAccessory | None for entity_id in entity_ids: aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) if aid not in self.bridge.accessories: continue - _LOGGER.info( - "HomeKit Bridge %s will reset accessory with linked entity_id %s", - self._name, - entity_id, - ) - acc = await self.async_remove_bridge_accessory(aid) - if acc: - await self._async_shutdown_accessory(acc) - if acc and (state := self.hass.states.get(acc.entity_id)): - new.append(state) - else: - _LOGGER.warning( - "The underlying entity %s disappeared during reset", entity_id - ) + if acc := self.async_remove_bridge_accessory(aid): + self._async_shutdown_accessory(acc) + removed.append(entity_id) + return removed - if not new: - # No matched accessories, probably on another bridge + async def _async_reset_accessories_in_bridge_mode( + self, entity_ids: Iterable[str] + ) -> None: + """Reset accessories in bridge mode.""" + if not (removed := self._async_remove_accessories_by_entity_id(entity_ids)): + _LOGGER.debug("No accessories to reset in bridge mode for: %s", entity_ids) return - - await self.async_config_changed() - await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) - for state in new: - if acc := self.add_bridge_accessory(state): - 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: - """Call config changed which writes out the new config to disk.""" + # With a reset, we need to remove the accessories, + # and force config change so iCloud deletes them from + # the database. assert self.driver is not None - await self.hass.async_add_executor_job(self.driver.config_changed) + self._async_update_accessories_hash() + await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) + await self._async_recreate_removed_accessories_in_bridge_mode(removed) + + async def _async_reload_accessories_in_bridge_mode( + self, entity_ids: Iterable[str] + ) -> None: + """Reload accessories in bridge mode.""" + removed = self._async_remove_accessories_by_entity_id(entity_ids) + await self._async_recreate_removed_accessories_in_bridge_mode(removed) + + async def _async_recreate_removed_accessories_in_bridge_mode( + self, removed: list[str] + ) -> None: + """Recreate removed accessories in bridge mode.""" + for entity_id in removed: + if not (state := self.hass.states.get(entity_id)): + _LOGGER.warning( + "The underlying entity %s disappeared during reload", entity_id + ) + continue + if acc := self.add_bridge_accessory(state): + # Run must be awaited here since it may change + # the accessories hash + await acc.run() + self._async_update_accessories_hash() + + @callback + def _async_update_accessories_hash(self) -> bool: + """Update the accessories hash.""" + assert self.driver is not None + driver = self.driver + old_hash = driver.state.accessories_hash + new_hash = driver.accessories_hash + if driver.state.set_accessories_hash(new_hash): + _LOGGER.debug( + "Updating HomeKit accessories hash from %s -> %s", old_hash, new_hash + ) + driver.async_persist() + driver.async_update_advertisement() + return True + _LOGGER.debug("HomeKit accessories hash is unchanged: %s", new_hash) + return False def add_bridge_accessory(self, state: State) -> HomeAccessory | None: """Try adding accessory to bridge if configured beforehand.""" @@ -734,7 +780,8 @@ class HomeKit: ) ) - async def async_remove_bridge_accessory(self, aid: int) -> HomeAccessory | None: + @callback + def async_remove_bridge_accessory(self, aid: int) -> HomeAccessory | None: """Try adding accessory to bridge if configured beforehand.""" assert self.bridge is not None if acc := self.bridge.accessories.pop(aid, None): @@ -782,6 +829,11 @@ class HomeKit: if self.status != STATUS_READY: return self.status = STATUS_WAIT + self._cancel_reload_dispatcher = async_dispatcher_connect( + self.hass, + f"homekit_reload_entities_{self._entry_id}", + self.async_reload_accessories, + ) async_zc_instance = await zeroconf.async_get_async_instance(self.hass) uuid = await instance_id.async_get(self.hass) self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id) @@ -797,7 +849,7 @@ class HomeKit: self._async_register_bridge() _LOGGER.debug("Driver start for %s", self._name) await self.driver.async_start() - async with self.hass.data[DOMAIN][PERSIST_LOCK]: + async with self.hass.data[PERSIST_LOCK_DATA]: await self.hass.async_add_executor_job(self.driver.persist) self.status = STATUS_RUNNING @@ -989,10 +1041,13 @@ class HomeKit: """Stop the accessory driver.""" if self.status != STATUS_RUNNING: return - self.status = STATUS_STOPPED - _LOGGER.debug("Driver stop for %s", self._name) - if self.driver: - await self.driver.async_stop() + async with self._reset_lock: + self.status = STATUS_STOPPED + assert self._cancel_reload_dispatcher is not None + self._cancel_reload_dispatcher() + _LOGGER.debug("Driver stop for %s", self._name) + if self.driver: + await self.driver.async_stop() @callback def _async_configure_linked_sensors( @@ -1107,14 +1162,16 @@ class HomeKitPairingQRView(HomeAssistantView): if not request.query_string: raise Unauthorized() entry_id, secret = request.query_string.split("-") - + hass: HomeAssistant = request.app["hass"] + domain_data: dict[str, HomeKitEntryData] = hass.data[DOMAIN] if ( - entry_id not in request.app["hass"].data[DOMAIN] - or secret - != request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET] + not (entry_data := domain_data.get(entry_id)) + or not secret + or not entry_data.pairing_qr_secret + or secret != entry_data.pairing_qr_secret ): raise Unauthorized() return web.Response( - body=request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR], + body=entry_data.pairing_qr, content_type="image/svg+xml", ) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 5a1e9bc1ea2..a14e0add488 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -17,6 +17,7 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.remote import RemoteEntityFeature from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, @@ -46,6 +47,7 @@ from homeassistant.core import ( callback as ha_callback, split_entity_id, ) +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, @@ -68,7 +70,6 @@ from .const import ( CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD, - DOMAIN, EVENT_HOMEKIT_CHANGED, HK_CHARGING, HK_NOT_CHARGABLE, @@ -80,7 +81,6 @@ from .const import ( MAX_VERSION_LENGTH, SERV_ACCESSORY_INFO, SERV_BATTERY_SERVICE, - SERVICE_HOMEKIT_RESET_ACCESSORY, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -110,6 +110,12 @@ SWITCH_TYPES = { } TYPES: Registry[str, type[HomeAccessory]] = Registry() +RELOAD_ON_CHANGE_ATTRS = ( + ATTR_SUPPORTED_FEATURES, + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, +) + def get_accessory( # noqa: C901 hass: HomeAssistant, driver: HomeDriver, state: State, aid: int | None, config: dict @@ -228,8 +234,12 @@ def get_accessory( # noqa: C901 a_type = "LightSensor" elif state.domain == "switch": - switch_type = config.get(CONF_TYPE, TYPE_SWITCH) - a_type = SWITCH_TYPES[switch_type] + if switch_type := config.get(CONF_TYPE): + a_type = SWITCH_TYPES[switch_type] + elif state.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET: + a_type = "Outlet" + else: + a_type = "Switch" elif state.domain == "vacuum": a_type = "Vacuum" @@ -267,6 +277,8 @@ def get_accessory( # noqa: C901 class HomeAccessory(Accessory): # type: ignore[misc] """Adapter class for Accessory.""" + driver: HomeDriver + def __init__( self, hass: HomeAssistant, @@ -289,6 +301,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] *args, # noqa: B026 **kwargs, ) + self._reload_on_change_attrs = list(RELOAD_ON_CHANGE_ATTRS) self.config = config or {} if device_id: self.device_id: str | None = device_id @@ -360,6 +373,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] """Add battery service if available""" state = self.hass.states.get(self.entity_id) + self._update_available_from_state(state) assert state is not None entity_attributes = state.attributes battery_found = entity_attributes.get(ATTR_BATTERY_LEVEL) @@ -402,16 +416,20 @@ class HomeAccessory(Accessory): # type: ignore[misc] CHAR_STATUS_LOW_BATTERY, value=0 ) + def _update_available_from_state(self, new_state: State | None) -> None: + """Update the available property based on the state.""" + self._available = new_state is not None and new_state.state != STATE_UNAVAILABLE + @property def available(self) -> bool: """Return if accessory is available.""" - state = self.hass.states.get(self.entity_id) - return state is not None and state.state != STATE_UNAVAILABLE + return self._available async def run(self) -> None: """Handle accessory driver started event.""" if state := self.hass.states.get(self.entity_id): self.async_update_state_callback(state) + self._update_available_from_state(state) self._subscriptions.append( async_track_state_change_event( self.hass, [self.entity_id], self.async_update_event_state_callback @@ -459,7 +477,28 @@ class HomeAccessory(Accessory): # type: ignore[misc] self, event: EventType[EventStateChangedData] ) -> None: """Handle state change event listener callback.""" - self.async_update_state_callback(event.data["new_state"]) + new_state = event.data["new_state"] + old_state = event.data["old_state"] + self._update_available_from_state(new_state) + if ( + new_state + and old_state + and STATE_UNAVAILABLE not in (old_state.state, new_state.state) + ): + old_attributes = old_state.attributes + new_attributes = new_state.attributes + for attr in self._reload_on_change_attrs: + if old_attributes.get(attr) != new_attributes.get(attr): + _LOGGER.debug( + "%s: Reloading HomeKit accessory since %s has changed from %s -> %s", + self.entity_id, + attr, + old_attributes.get(attr), + new_attributes.get(attr), + ) + self.async_reload() + return + self.async_update_state_callback(new_state) @ha_callback def async_update_state_callback(self, new_state: State | None) -> None: @@ -572,21 +611,30 @@ class HomeAccessory(Accessory): # type: ignore[misc] ) @ha_callback - def async_reset(self) -> None: - """Reset and recreate an accessory.""" - self.hass.async_create_task( - self.hass.services.async_call( - DOMAIN, - SERVICE_HOMEKIT_RESET_ACCESSORY, - {ATTR_ENTITY_ID: self.entity_id}, - ) + def async_reload(self) -> None: + """Reload and recreate an accessory and update the c# value in the mDNS record.""" + async_dispatcher_send( + self.hass, + f"homekit_reload_entities_{self.driver.entry_id}", + (self.entity_id,), ) - async def stop(self) -> None: + @ha_callback + def async_stop(self) -> None: """Cancel any subscriptions when the bridge is stopped.""" while self._subscriptions: self._subscriptions.pop(0)() + async def stop(self) -> None: + """Stop the accessory. + + This is overrides the parent class to call async_stop + since pyhap will call this function to stop the accessory + but we want to use our async_stop method since we need + it to be a callback to avoid races in reloading accessories. + """ + self.async_stop() + class HomeBridge(Bridge): # type: ignore[misc] """Adapter class for Bridge.""" @@ -632,7 +680,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] """Initialize a AccessoryDriver object.""" super().__init__(**kwargs) self.hass = hass - self._entry_id = entry_id + self.entry_id = entry_id self._bridge_name = bridge_name self._entry_title = entry_title self.iid_storage = iid_storage @@ -644,7 +692,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] """Override super function to dismiss setup message if paired.""" success = super().pair(client_username_bytes, client_public, client_permissions) if success: - async_dismiss_setup_message(self.hass, self._entry_id) + async_dismiss_setup_message(self.hass, self.entry_id) return cast(bool, success) @pyhap_callback # type: ignore[misc] @@ -657,7 +705,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] async_show_setup_message( self.hass, - self._entry_id, + self.entry_id, accessory_friendly_name(self._entry_title, self.accessory), self.state.pincode, self.accessory.xhm_uri(), diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 9c3d9e7929c..43beaaa8dc6 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -33,9 +33,9 @@ AID_MIN = 2 AID_MAX = 18446744073709551615 -def get_system_unique_id(entity: er.RegistryEntry) -> str: +def get_system_unique_id(entity: er.RegistryEntry, entity_unique_id: str) -> str: """Determine the system wide unique_id for an entity.""" - return f"{entity.platform}.{entity.domain}.{entity.unique_id}" + return f"{entity.platform}.{entity.domain}.{entity_unique_id}" def _generate_aids(unique_id: str | None, entity_id: str) -> Generator[int, None, None]: @@ -72,31 +72,42 @@ class AccessoryAidStorage: self.allocated_aids: set[int] = set() self._entry_id = entry_id self.store: Store | None = None - self._entity_registry: er.EntityRegistry | None = None + self._entity_registry = er.async_get(hass) async def async_initialize(self) -> None: """Load the latest AID data.""" - self._entity_registry = er.async_get(self.hass) aidstore = get_aid_storage_filename_for_entry_id(self._entry_id) self.store = Store(self.hass, AID_MANAGER_STORAGE_VERSION, aidstore) if not (raw_storage := await self.store.async_load()): # There is no data about aid allocations yet return - assert isinstance(raw_storage, dict) self.allocations = raw_storage.get(ALLOCATIONS_KEY, {}) self.allocated_aids = set(self.allocations.values()) def get_or_allocate_aid_for_entity_id(self, entity_id: str) -> int: """Generate a stable aid for an entity id.""" - assert self._entity_registry is not None - if not (entity := self._entity_registry.async_get(entity_id)): + if not (entry := self._entity_registry.async_get(entity_id)): return self.get_or_allocate_aid(None, entity_id) - sys_unique_id = get_system_unique_id(entity) + sys_unique_id = get_system_unique_id(entry, entry.unique_id) + self._migrate_unique_id_aid_assignment_if_needed(sys_unique_id, entry) return self.get_or_allocate_aid(sys_unique_id, entity_id) + def _migrate_unique_id_aid_assignment_if_needed( + self, sys_unique_id: str, entry: er.RegistryEntry + ) -> None: + """Migrate the unique id aid assignment if its changed.""" + if sys_unique_id in self.allocations or not ( + previous_unique_id := entry.previous_unique_id + ): + return + old_sys_unique_id = get_system_unique_id(entry, previous_unique_id) + if aid := self.allocations.pop(old_sys_unique_id, None): + self.allocations[sys_unique_id] = aid + self.async_schedule_save() + def get_or_allocate_aid(self, unique_id: str | None, entity_id: str) -> int: """Allocate (and return) a new aid for an accessory.""" if unique_id and unique_id in self.allocations: @@ -140,6 +151,6 @@ class AccessoryAidStorage: return await self.store.async_save(self._data_to_save()) @callback - def _data_to_save(self) -> dict: + def _data_to_save(self) -> dict[str, dict[str, int]]: """Return data of entity map to store in a file.""" return {ALLOCATIONS_KEY: self.allocations} diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index c43093d92b4..a6984ae2121 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -257,7 +257,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) ) - async def async_step_accessory(self, accessory_input: dict) -> FlowResult: + async def async_step_accessory(self, accessory_input: dict[str, Any]) -> FlowResult: """Handle creation a single accessory in accessory mode.""" entity_id = accessory_input[CONF_ENTITY_ID] port = accessory_input[CONF_PORT] @@ -283,7 +283,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=f"{name}:{entry_data[CONF_PORT]}", data=entry_data ) - async def async_step_import(self, user_input: dict) -> FlowResult: + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Handle import from yaml.""" if not self._async_is_unique_name_port(user_input): return self.async_abort(reason="port_name_in_use") @@ -318,7 +318,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return suggested_name @callback - def _async_is_unique_name_port(self, user_input: dict[str, str]) -> bool: + def _async_is_unique_name_port(self, user_input: dict[str, Any]) -> bool: """Determine is a name or port is already used.""" name = user_input[CONF_NAME] port = user_input[CONF_PORT] diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index bb5ae1ffd1c..5a7ee1d9576 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -6,13 +6,10 @@ from homeassistant.const import CONF_DEVICES DEBOUNCE_TIMEOUT = 0.5 DEVICE_PRECISION_LEEWAY = 6 DOMAIN = "homekit" +PERSIST_LOCK_DATA = f"{DOMAIN}_persist_lock" HOMEKIT_FILE = ".homekit.state" -HOMEKIT_PAIRING_QR = "homekit-pairing-qr" -HOMEKIT_PAIRING_QR_SECRET = "homekit-pairing-qr-secret" -HOMEKIT = "homekit" SHUTDOWN_TIMEOUT = 30 CONF_ENTRY_INDEX = "index" -PERSIST_LOCK = "persist_lock" # ### Codecs #### VIDEO_CODEC_COPY = "copy" diff --git a/homeassistant/components/homekit/diagnostics.py b/homeassistant/components/homekit/diagnostics.py index f27171e6eae..347a3df0dd4 100644 --- a/homeassistant/components/homekit/diagnostics.py +++ b/homeassistant/components/homekit/diagnostics.py @@ -10,9 +10,9 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import HomeKit from .accessories import HomeAccessory, HomeBridge -from .const import DOMAIN, HOMEKIT +from .const import DOMAIN +from .models import HomeKitEntryData TO_REDACT = {"access_token", "entity_picture"} @@ -21,7 +21,8 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - homekit: HomeKit = hass.data[DOMAIN][entry.entry_id][HOMEKIT] + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry.entry_id] + homekit = entry_data.homekit data: dict[str, Any] = { "status": homekit.status, "config-entry": { diff --git a/homeassistant/components/homekit/iidmanager.py b/homeassistant/components/homekit/iidmanager.py index 2bd50821138..f44d76d3ee7 100644 --- a/homeassistant/components/homekit/iidmanager.py +++ b/homeassistant/components/homekit/iidmanager.py @@ -36,7 +36,7 @@ class IIDStorage(Store): old_major_version: int, old_minor_version: int, old_data: dict, - ): + ) -> dict: """Migrate to the new version.""" if old_major_version == 1: # Convert v1 to v2 format which uses a unique iid set per accessory diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 67f99ad5f8b..17d1237e579 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,8 +9,8 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.8.0", - "fnv-hash-fast==0.4.1", + "HAP-python==4.9.1", + "fnv-hash-fast==0.5.0", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/models.py b/homeassistant/components/homekit/models.py new file mode 100644 index 00000000000..e96af00fead --- /dev/null +++ b/homeassistant/components/homekit/models.py @@ -0,0 +1,15 @@ +"""Models for the HomeKit component.""" +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from . import HomeKit + + +@dataclass +class HomeKitEntryData: + """Class to hold HomeKit data.""" + + homekit: "HomeKit" + pairing_qr: bytes | None = None + pairing_qr_secret: str | None = None diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 4c7ba5a7841..ed26265be24 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any from haffmpeg.core import FFMPEG_STDERR, HAFFmpeg from pyhap.camera import ( @@ -14,7 +15,7 @@ from pyhap.const import CATEGORY_CAMERA from homeassistant.components import camera from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.const import STATE_ON -from homeassistant.core import State, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, @@ -22,7 +23,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.typing import EventType -from .accessories import TYPES, HomeAccessory +from .accessories import TYPES, HomeAccessory, HomeDriver from .const import ( CHAR_MOTION_DETECTED, CHAR_MUTE, @@ -138,10 +139,18 @@ CONFIG_DEFAULTS = { @TYPES.register("Camera") -class Camera(HomeAccessory, PyhapCamera): +class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] """Generate a Camera accessory.""" - def __init__(self, hass, driver, name, entity_id, aid, config): + def __init__( + self, + hass: HomeAssistant, + driver: HomeDriver, + name: str, + entity_id: str, + aid: int, + config: dict[str, Any], + ) -> None: """Initialize a Camera accessory object.""" self._ffmpeg = get_ffmpeg_manager(hass) for config_key, conf in CONFIG_DEFAULTS.items(): @@ -242,12 +251,13 @@ class Camera(HomeAccessory, PyhapCamera): self._async_update_doorbell_state(state) - async def run(self): + async def run(self) -> None: """Handle accessory driver started event. Run inside the Home Assistant event loop. """ if self._char_motion_detected: + assert self.linked_motion_sensor self._subscriptions.append( async_track_state_change_event( self.hass, @@ -257,6 +267,7 @@ class Camera(HomeAccessory, PyhapCamera): ) if self._char_doorbell_detected: + assert self.linked_doorbell_sensor self._subscriptions.append( async_track_state_change_event( self.hass, @@ -282,6 +293,7 @@ class Camera(HomeAccessory, PyhapCamera): return detected = new_state.state == STATE_ON + assert self._char_motion_detected if self._char_motion_detected.value == detected: return @@ -307,6 +319,8 @@ class Camera(HomeAccessory, PyhapCamera): if not new_state: return + assert self._char_doorbell_detected + assert self._char_doorbell_detected_switch if new_state.state == STATE_ON: self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS) self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS) @@ -318,13 +332,13 @@ class Camera(HomeAccessory, PyhapCamera): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State | None) -> None: """Handle state change to update HomeKit value.""" - pass # pylint: disable=unnecessary-pass - async def _async_get_stream_source(self): + async def _async_get_stream_source(self) -> str | None: """Find the camera stream source url.""" - if stream_source := self.config.get(CONF_STREAM_SOURCE): + stream_source: str | None = self.config.get(CONF_STREAM_SOURCE) + if stream_source: return stream_source try: stream_source = await camera.async_get_stream_source( @@ -337,7 +351,9 @@ class Camera(HomeAccessory, PyhapCamera): ) return stream_source - async def start_stream(self, session_info, stream_config): + async def start_stream( + self, session_info: dict[str, Any], stream_config: dict[str, Any] + ) -> bool: """Start a new stream with the given configuration.""" _LOGGER.debug( "[%s] Starting stream with the following parameters: %s", @@ -404,7 +420,7 @@ class Camera(HomeAccessory, PyhapCamera): stderr_reader = await stream.get_reader(source=FFMPEG_STDERR) - async def watch_session(_): + async def watch_session(_: Any) -> None: await self._async_ffmpeg_watch(session_info["id"]) session_info[FFMPEG_LOGGER] = asyncio.create_task( @@ -418,7 +434,9 @@ class Camera(HomeAccessory, PyhapCamera): return await self._async_ffmpeg_watch(session_info["id"]) - async def _async_log_stderr_stream(self, stderr_reader): + async def _async_log_stderr_stream( + self, stderr_reader: asyncio.StreamReader + ) -> None: """Log output from ffmpeg.""" _LOGGER.debug("%s: ffmpeg: started", self.display_name) while True: @@ -428,7 +446,7 @@ class Camera(HomeAccessory, PyhapCamera): _LOGGER.debug("%s: ffmpeg: %s", self.display_name, line.rstrip()) - async def _async_ffmpeg_watch(self, session_id): + async def _async_ffmpeg_watch(self, session_id: str) -> bool: """Check to make sure ffmpeg is still running and cleanup if not.""" ffmpeg_pid = self.sessions[session_id][FFMPEG_PID] if pid_is_alive(ffmpeg_pid): @@ -440,22 +458,23 @@ class Camera(HomeAccessory, PyhapCamera): return False @callback - def _async_stop_ffmpeg_watch(self, session_id): + def _async_stop_ffmpeg_watch(self, session_id: str) -> None: """Cleanup a streaming session after stopping.""" if FFMPEG_WATCHER not in self.sessions[session_id]: return self.sessions[session_id].pop(FFMPEG_WATCHER)() self.sessions[session_id].pop(FFMPEG_LOGGER).cancel() - async def stop(self): + @callback + def async_stop(self) -> None: """Stop any streams when the accessory is stopped.""" for session_info in self.sessions.values(): self.hass.async_create_background_task( self.stop_stream(session_info), "homekit.camera-stop-stream" ) - await super().stop() + super().async_stop() - async def stop_stream(self, session_info): + async def stop_stream(self, session_info: dict[str, Any]) -> None: """Stop the stream for the given ``session_id``.""" session_id = session_info["id"] if not (stream := session_info.get("stream")): @@ -466,7 +485,7 @@ class Camera(HomeAccessory, PyhapCamera): if not pid_is_alive(stream.process.pid): _LOGGER.info("[%s] Stream already stopped", session_id) - return True + return for shutdown_method in ("close", "kill"): _LOGGER.info("[%s] %s stream", session_id, shutdown_method) @@ -478,11 +497,13 @@ class Camera(HomeAccessory, PyhapCamera): "[%s] Failed to %s stream", session_id, shutdown_method ) - async def reconfigure_stream(self, session_info, stream_config): + async def reconfigure_stream( + self, session_info: dict[str, Any], stream_config: dict[str, Any] + ) -> bool: """Reconfigure the stream so that it uses the given ``stream_config``.""" return True - async def async_get_snapshot(self, image_size): + async def async_get_snapshot(self, image_size: dict[str, int]) -> bytes: """Return a jpeg of a snapshot from the camera.""" image = await camera.async_get_image( self.hass, diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index ea0a5054ffd..1d60d405502 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -1,5 +1,6 @@ """Class to hold all cover accessories.""" import logging +from typing import Any from pyhap.const import ( CATEGORY_DOOR, @@ -7,6 +8,7 @@ from pyhap.const import ( CATEGORY_WINDOW, CATEGORY_WINDOW_COVERING, ) +from pyhap.service import Service from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -98,10 +100,11 @@ class GarageDoorOpener(HomeAccessory): and support no more than open, close, and stop. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a GarageDoorOpener accessory object.""" super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) state = self.hass.states.get(self.entity_id) + assert state serv_garage_door = self.add_preload_service(SERV_GARAGE_DOOR_OPENER) self.char_current_state = serv_garage_door.configure_char( @@ -122,7 +125,7 @@ class GarageDoorOpener(HomeAccessory): self.async_update_state(state) - async def run(self): + async def run(self) -> None: """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -163,7 +166,7 @@ class GarageDoorOpener(HomeAccessory): detected, ) - def set_state(self, value): + def set_state(self, value: int) -> None: """Change garage state if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) @@ -178,7 +181,7 @@ class GarageDoorOpener(HomeAccessory): self.async_call_service(DOMAIN, SERVICE_CLOSE_COVER, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update cover state after state changed.""" hass_state = new_state.state target_door_state = DOOR_TARGET_HASS_TO_HK.get(hass_state) @@ -203,12 +206,12 @@ class OpeningDeviceBase(HomeAccessory): WindowCovering """ - def __init__(self, *args, category, service): + def __init__(self, *args: Any, category: int, service: Service) -> None: """Initialize a OpeningDeviceBase accessory object.""" super().__init__(*args, category=category) state = self.hass.states.get(self.entity_id) - - self.features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + assert state + self.features: int = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) self._supports_stop = self.features & CoverEntityFeature.STOP self.chars = [] if self._supports_stop: @@ -233,7 +236,7 @@ class OpeningDeviceBase(HomeAccessory): CHAR_CURRENT_TILT_ANGLE, value=0 ) - def set_stop(self, value): + def set_stop(self, value: int) -> None: """Stop the cover motion from HomeKit.""" if value != 1: return @@ -241,7 +244,7 @@ class OpeningDeviceBase(HomeAccessory): DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.entity_id} ) - def set_tilt(self, value): + def set_tilt(self, value: float) -> None: """Set tilt to value if call came from HomeKit.""" _LOGGER.info("%s: Set tilt to %d", self.entity_id, value) @@ -254,7 +257,7 @@ class OpeningDeviceBase(HomeAccessory): self.async_call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update cover position and tilt after state changed.""" # update tilt if not self._supports_tilt: @@ -276,14 +279,15 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): The cover entity must support: set_cover_position. """ - def __init__(self, *args, category, service): + def __init__(self, *args: Any, category: int, service: Service) -> None: """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=category, service=service) state = self.hass.states.get(self.entity_id) + assert state self.char_current_position = self.serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 ) - target_args = {"value": 0} + target_args: dict[str, Any] = {"value": 0} if self.features & CoverEntityFeature.SET_POSITION: target_args["setter_callback"] = self.move_cover else: @@ -307,7 +311,7 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): ) self.async_update_state(state) - def move_cover(self, value): + def move_cover(self, value: int) -> None: """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} @@ -338,7 +342,7 @@ class Door(OpeningDevice): The entity must support: set_cover_position. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Door accessory object.""" super().__init__(*args, category=CATEGORY_DOOR, service=SERV_DOOR) @@ -350,7 +354,7 @@ class Window(OpeningDevice): The entity must support: set_cover_position. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Window accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW, service=SERV_WINDOW) @@ -362,7 +366,7 @@ class WindowCovering(OpeningDevice): The entity must support: set_cover_position. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a WindowCovering accessory object.""" super().__init__( *args, category=CATEGORY_WINDOW_COVERING, service=SERV_WINDOW_COVERING @@ -377,12 +381,13 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): stop_cover (optional). """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a WindowCoveringBasic accessory object.""" super().__init__( *args, category=CATEGORY_WINDOW_COVERING, service=SERV_WINDOW_COVERING ) state = self.hass.states.get(self.entity_id) + assert state self.char_current_position = self.serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 ) @@ -394,7 +399,7 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): ) self.async_update_state(state) - def move_cover(self, value): + def move_cover(self, value: int) -> None: """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) @@ -436,7 +441,7 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): super().async_update_state(new_state) -def _hass_state_to_position_start(state): +def _hass_state_to_position_start(state: str) -> int: """Convert hass state to homekit position state.""" if state == STATE_OPENING: return HK_POSITION_GOING_TO_MAX diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index e3116c99e26..9b27653e4cf 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -1,5 +1,6 @@ -"""Class to hold all light accessories.""" +"""Class to hold all fan accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_FAN @@ -27,7 +28,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( @@ -54,15 +55,22 @@ class Fan(HomeAccessory): Currently supports: state, speed, oscillate, direction. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a new Fan accessory object.""" super().__init__(*args, category=CATEGORY_FAN) - self.chars = [] + self.chars: list[str] = [] state = self.hass.states.get(self.entity_id) + assert state + self._reload_on_change_attrs.extend( + ( + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODES, + ) + ) features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) percentage_step = state.attributes.get(ATTR_PERCENTAGE_STEP, 1) - self.preset_modes = state.attributes.get(ATTR_PRESET_MODES) + self.preset_modes: list[str] | None = state.attributes.get(ATTR_PRESET_MODES) if features & FanEntityFeature.DIRECTION: self.chars.append(CHAR_ROTATION_DIRECTION) @@ -129,7 +137,7 @@ class Fan(HomeAccessory): self.async_update_state(state) serv_fan.setter_callback = self._set_chars - def _set_chars(self, char_values): + def _set_chars(self, char_values: dict[str, Any]) -> None: _LOGGER.debug("Fan _set_chars: %s", char_values) if CHAR_ACTIVE in char_values: if char_values[CHAR_ACTIVE]: @@ -160,23 +168,23 @@ class Fan(HomeAccessory): if CHAR_TARGET_FAN_STATE in char_values: self.set_single_preset_mode(char_values[CHAR_TARGET_FAN_STATE]) - def set_single_preset_mode(self, value): + def set_single_preset_mode(self, value: int) -> None: """Set auto call came from HomeKit.""" - params = {ATTR_ENTITY_ID: self.entity_id} + params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_id} if value: + assert self.preset_modes _LOGGER.debug( "%s: Set auto to 1 (%s)", self.entity_id, self.preset_modes[0] ) params[ATTR_PRESET_MODE] = self.preset_modes[0] self.async_call_service(DOMAIN, SERVICE_SET_PRESET_MODE, params) - else: - current_state = self.hass.states.get(self.entity_id) - percentage = current_state.attributes.get(ATTR_PERCENTAGE) or 50 + elif current_state := self.hass.states.get(self.entity_id): + percentage: float = current_state.attributes.get(ATTR_PERCENTAGE) or 50.0 params[ATTR_PERCENTAGE] = percentage _LOGGER.debug("%s: Set auto to 0", self.entity_id) self.async_call_service(DOMAIN, SERVICE_TURN_ON, params) - def set_preset_mode(self, value, preset_mode): + def set_preset_mode(self, value: int, preset_mode: str) -> None: """Set preset_mode if call came from HomeKit.""" _LOGGER.debug( "%s: Set preset_mode %s to %d", self.entity_id, preset_mode, value @@ -188,35 +196,35 @@ class Fan(HomeAccessory): else: self.async_call_service(DOMAIN, SERVICE_TURN_ON, params) - def set_state(self, value): + def set_state(self, value: int) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_direction(self, value): + def set_direction(self, value: int) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set direction to %d", self.entity_id, value) direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD params = {ATTR_ENTITY_ID: self.entity_id, ATTR_DIRECTION: direction} self.async_call_service(DOMAIN, SERVICE_SET_DIRECTION, params, direction) - def set_oscillating(self, value): + def set_oscillating(self, value: int) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set oscillating to %d", self.entity_id, value) oscillating = value == 1 params = {ATTR_ENTITY_ID: self.entity_id, ATTR_OSCILLATING: oscillating} self.async_call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating) - def set_percentage(self, value): + def set_percentage(self, value: float) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set speed to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_PERCENTAGE: value} self.async_call_service(DOMAIN, SERVICE_SET_PERCENTAGE, params, value) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update fan after state change.""" # Handle State state = new_state.state diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index f9f572a096c..939c1bf37ae 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -1,9 +1,11 @@ """Class to hold all thermostat accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_HUMIDIFIER from homeassistant.components.humidifier import ( + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, @@ -63,21 +65,47 @@ HC_DEVICE_CLASS_TO_TARGET_CHAR = { HC_DEHUMIDIFIER: CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY, } + HC_STATE_INACTIVE = 0 HC_STATE_IDLE = 1 HC_STATE_HUMIDIFYING = 2 HC_STATE_DEHUMIDIFYING = 3 +BASE_VALID_VALUES = { + "Inactive": HC_STATE_INACTIVE, + "Idle": HC_STATE_IDLE, +} + +VALID_VALUES_BY_DEVICE_CLASS = { + HumidifierDeviceClass.HUMIDIFIER: { + **BASE_VALID_VALUES, + "Humidifying": HC_STATE_HUMIDIFYING, + }, + HumidifierDeviceClass.DEHUMIDIFIER: { + **BASE_VALID_VALUES, + "Dehumidifying": HC_STATE_DEHUMIDIFYING, + }, +} + @TYPES.register("HumidifierDehumidifier") class HumidifierDehumidifier(HomeAccessory): """Generate a HumidifierDehumidifier accessory for a humidifier.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a HumidifierDehumidifier accessory object.""" super().__init__(*args, category=CATEGORY_HUMIDIFIER) - self.chars = [] - state = self.hass.states.get(self.entity_id) + self._reload_on_change_attrs.extend( + ( + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + ) + ) + + self.chars: list[str] = [] + states = self.hass.states + state = states.get(self.entity_id) + assert state device_class = state.attributes.get( ATTR_DEVICE_CLASS, HumidifierDeviceClass.HUMIDIFIER ) @@ -95,7 +123,9 @@ class HumidifierDehumidifier(HomeAccessory): # Current and target mode characteristics self.char_current_humidifier_dehumidifier = ( serv_humidifier_dehumidifier.configure_char( - CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER, value=0 + CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER, + value=0, + valid_values=VALID_VALUES_BY_DEVICE_CLASS[device_class], ) ) self.char_target_humidifier_dehumidifier = ( @@ -140,11 +170,10 @@ class HumidifierDehumidifier(HomeAccessory): self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR) if self.linked_humidity_sensor: - humidity_state = self.hass.states.get(self.linked_humidity_sensor) - if humidity_state: + if humidity_state := states.get(self.linked_humidity_sensor): self._async_update_current_humidity(humidity_state) - async def run(self): + async def run(self) -> None: """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -182,14 +211,6 @@ class HumidifierDehumidifier(HomeAccessory): return try: current_humidity = float(new_state.state) - if self.char_current_humidity.value != current_humidity: - _LOGGER.debug( - "%s: Linked humidity sensor %s changed to %d", - self.entity_id, - self.linked_humidity_sensor, - current_humidity, - ) - self.char_current_humidity.set_value(current_humidity) except ValueError as ex: _LOGGER.debug( "%s: Unable to update from linked humidity sensor %s: %s", @@ -197,8 +218,23 @@ class HumidifierDehumidifier(HomeAccessory): self.linked_humidity_sensor, ex, ) + return + self._async_update_current_humidity_value(current_humidity) - def _set_chars(self, char_values): + @callback + def _async_update_current_humidity_value(self, current_humidity: float) -> None: + """Handle linked humidity or built-in humidity.""" + if self.char_current_humidity.value != current_humidity: + _LOGGER.debug( + "%s: Linked humidity sensor %s changed to %d", + self.entity_id, + self.linked_humidity_sensor, + current_humidity, + ) + self.char_current_humidity.set_value(current_humidity) + + def _set_chars(self, char_values: dict[str, Any]) -> None: + """Set characteristics based on the data coming from HomeKit.""" _LOGGER.debug("HumidifierDehumidifier _set_chars: %s", char_values) if CHAR_TARGET_HUMIDIFIER_DEHUMIDIFIER in char_values: @@ -218,14 +254,8 @@ 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) - + assert state + min_humidity, max_humidity = self.get_humidity_range(state) humidity = round(char_values[self._target_humidity_char_name]) if (humidity < min_humidity) or (humidity > max_humidity): @@ -244,10 +274,22 @@ class HumidifierDehumidifier(HomeAccessory): ), ) + def get_humidity_range(self, state: State) -> tuple[int, int]: + """Return min and max humidity range.""" + attributes = state.attributes + min_humidity = max( + int(round(attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY))), 0 + ) + max_humidity = min( + int(round(attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY))), 100 + ) + return min_humidity, max_humidity + @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update state without rechecking the device features.""" is_active = new_state.state == STATE_ON + attributes = new_state.attributes # Update active state self.char_active.set_value(is_active) @@ -263,6 +305,9 @@ class HumidifierDehumidifier(HomeAccessory): self.char_current_humidifier_dehumidifier.set_value(current_state) # Update target humidity - target_humidity = new_state.attributes.get(ATTR_HUMIDITY) + target_humidity = attributes.get(ATTR_HUMIDITY) if isinstance(target_humidity, (int, float)): self.char_target_humidity.set_value(target_humidity) + current_humidity = attributes.get(ATTR_CURRENT_HUMIDITY) + if isinstance(current_humidity, (int, float)): + self.char_current_humidity.set_value(current_humidity) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 83ce1c3f6cf..b45e9e1c17b 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -1,7 +1,9 @@ """Class to hold all light accessories.""" from __future__ import annotations +from datetime import datetime import logging +from typing import Any from pyhap.const import CATEGORY_LIGHTBULB @@ -29,7 +31,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, State, callback from homeassistant.helpers.event import async_call_later from homeassistant.util.color import ( color_temperature_kelvin_to_mired, @@ -68,15 +70,22 @@ class Light(HomeAccessory): Currently supports: state, brightness, color temperature, rgb_color. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a new Light accessory object.""" super().__init__(*args, category=CATEGORY_LIGHTBULB) - + self._reload_on_change_attrs.extend( + ( + ATTR_SUPPORTED_COLOR_MODES, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ) + ) self.chars = [] - self._event_timer = None - self._pending_events = {} + self._event_timer: CALLBACK_TYPE | None = None + self._pending_events: dict[str, Any] = {} state = self.hass.states.get(self.entity_id) + assert state attributes = state.attributes self.color_modes = color_modes = ( attributes.get(ATTR_SUPPORTED_COLOR_MODES) or [] @@ -134,7 +143,7 @@ class Light(HomeAccessory): self.async_update_state(state) serv_light.setter_callback = self._set_chars - def _set_chars(self, char_values): + def _set_chars(self, char_values: dict[str, Any]) -> None: _LOGGER.debug("Light _set_chars: %s", char_values) # Newest change always wins if CHAR_COLOR_TEMPERATURE in self._pending_events and ( @@ -153,14 +162,14 @@ class Light(HomeAccessory): ) @callback - def _async_send_events(self, *_): + def _async_send_events(self, _now: datetime) -> None: """Process all changes at once.""" _LOGGER.debug("Coalesced _set_chars: %s", self._pending_events) char_values = self._pending_events self._pending_events = {} events = [] service = SERVICE_TURN_ON - params = {ATTR_ENTITY_ID: self.entity_id} + params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_id} if CHAR_ON in char_values: if not char_values[CHAR_ON]: @@ -225,7 +234,7 @@ class Light(HomeAccessory): self.async_call_service(DOMAIN, service, params, ", ".join(events)) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update light after state change.""" # Handle State state = new_state.state @@ -265,8 +274,11 @@ class Light(HomeAccessory): hue, saturation = color_temperature_to_hs(color_temp) elif color_mode == ColorMode.WHITE: hue, saturation = 0, 0 + elif hue_sat := attributes.get(ATTR_HS_COLOR): + hue, saturation = hue_sat else: - hue, saturation = attributes.get(ATTR_HS_COLOR, (None, None)) + hue = None + saturation = None if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): self.char_hue.set_value(round(hue, 0)) self.char_saturation.set_value(round(saturation, 0)) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index da7fdceede3..23fbd5b454d 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -2,6 +2,7 @@ import logging from typing import Any +from pyhap.characteristic import Characteristic from pyhap.const import CATEGORY_SWITCH from homeassistant.components.media_player import ( @@ -32,7 +33,7 @@ from homeassistant.const import ( STATE_STANDBY, STATE_UNKNOWN, ) -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( @@ -82,11 +83,12 @@ MEDIA_PLAYER_OFF_STATES = ( class MediaPlayer(HomeAccessory): """Generate a Media Player accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) state = self.hass.states.get(self.entity_id) - self.chars = { + assert state + self.chars: dict[str, Characteristic | None] = { FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None, FEATURE_PLAY_STOP: None, @@ -137,20 +139,20 @@ class MediaPlayer(HomeAccessory): ) self.async_update_state(state) - def generate_service_name(self, mode): + def generate_service_name(self, mode: str) -> str: """Generate name for individual service.""" return cleanup_name_for_homekit( f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}" ) - def set_on_off(self, value): + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_play_pause(self, value): + def set_play_pause(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug( '%s: Set switch state for "play_pause" to %s', self.entity_id, value @@ -159,7 +161,7 @@ class MediaPlayer(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_play_stop(self, value): + def set_play_stop(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug( '%s: Set switch state for "play_stop" to %s', self.entity_id, value @@ -168,7 +170,7 @@ class MediaPlayer(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_toggle_mute(self, value): + def set_toggle_mute(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug( '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value @@ -177,43 +179,43 @@ class MediaPlayer(HomeAccessory): self.async_call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_state = new_state.state - if self.chars[FEATURE_ON_OFF]: + if on_off_char := self.chars[FEATURE_ON_OFF]: hk_state = current_state not in MEDIA_PLAYER_OFF_STATES _LOGGER.debug( '%s: Set current state for "on_off" to %s', self.entity_id, hk_state ) - self.chars[FEATURE_ON_OFF].set_value(hk_state) + on_off_char.set_value(hk_state) - if self.chars[FEATURE_PLAY_PAUSE]: + if play_pause_char := self.chars[FEATURE_PLAY_PAUSE]: hk_state = current_state == STATE_PLAYING _LOGGER.debug( '%s: Set current state for "play_pause" to %s', self.entity_id, hk_state, ) - self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) + play_pause_char.set_value(hk_state) - if self.chars[FEATURE_PLAY_STOP]: + if play_stop_char := self.chars[FEATURE_PLAY_STOP]: hk_state = current_state == STATE_PLAYING _LOGGER.debug( '%s: Set current state for "play_stop" to %s', self.entity_id, hk_state, ) - self.chars[FEATURE_PLAY_STOP].set_value(hk_state) + play_stop_char.set_value(hk_state) - if self.chars[FEATURE_TOGGLE_MUTE]: - current_state = bool(new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)) + if toggle_mute_char := self.chars[FEATURE_TOGGLE_MUTE]: + mute_state = bool(new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)) _LOGGER.debug( '%s: Set current state for "toggle_mute" to %s', self.entity_id, - current_state, + mute_state, ) - self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) + toggle_mute_char.set_value(mute_state) @TYPES.register("TelevisionMediaPlayer") @@ -278,14 +280,14 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): self.async_update_state(state) - def set_on_off(self, value): + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_mute(self, value): + def set_mute(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug( '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value @@ -293,27 +295,27 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_MUTED: value} self.async_call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) - def set_volume(self, value): + def set_volume(self, value: bool) -> None: """Send volume step value if call came from HomeKit.""" _LOGGER.debug("%s: Set volume to %s", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_LEVEL: value} self.async_call_service(DOMAIN, SERVICE_VOLUME_SET, params) - def set_volume_step(self, value): + def set_volume_step(self, value: bool) -> None: """Send volume step value if call came from HomeKit.""" _LOGGER.debug("%s: Step volume by %s", self.entity_id, value) service = SERVICE_VOLUME_DOWN if value else SERVICE_VOLUME_UP params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_input_source(self, value): + def set_input_source(self, value: int) -> None: """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) source_name = self._mapped_sources[self.sources[value]] params = {ATTR_ENTITY_ID: self.entity_id, ATTR_INPUT_SOURCE: source_name} self.async_call_service(DOMAIN, SERVICE_SELECT_SOURCE, params) - def set_remote_key(self, value): + def set_remote_key(self, value: int) -> None: """Send remote key value if call came from HomeKit.""" _LOGGER.debug("%s: Set remote key to %s", self.entity_id, value) if (key_name := REMOTE_KEYS.get(value)) is None: @@ -322,7 +324,9 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): if key_name == KEY_PLAY_PAUSE and self._supports_play_pause: # Handle Play Pause by directly updating the media player entity. - state = self.hass.states.get(self.entity_id).state + state_obj = self.hass.states.get(self.entity_id) + assert state_obj + state = state_obj.state if state in (STATE_PLAYING, STATE_PAUSED): service = ( SERVICE_MEDIA_PLAY if state == STATE_PAUSED else SERVICE_MEDIA_PAUSE @@ -340,7 +344,7 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update Television state after state changed.""" current_state = new_state.state diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index e440a5b3ac0..e03b14f943a 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -93,7 +93,7 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): state = self.hass.states.get(self.entity_id) assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - + self._reload_on_change_attrs.extend((source_list_key,)) self._mapped_sources_list: list[str] = [] self._mapped_sources: dict[str, str] = {} self.source_key = source_key @@ -165,19 +165,19 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): return list(self._get_mapped_sources(state)) @abstractmethod - def set_on_off(self, value): + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" @abstractmethod - def set_input_source(self, value): + def set_input_source(self, value: int) -> None: """Send input set value if call came from HomeKit.""" @abstractmethod - def set_remote_key(self, value): + def set_remote_key(self, value: int) -> None: """Send remote key value if call came from HomeKit.""" @callback - def _async_update_input_state(self, hk_state, new_state): + def _async_update_input_state(self, hk_state: int, new_state: State) -> None: """Update input state after state changed.""" # Set active input if not self.support_select_source or not self.sources: @@ -204,8 +204,6 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): "%s: Sources out of sync. Rebuilding Accessory", self.entity_id, ) - # Sources are out of sync, recreate the accessory - self.async_reset() return _LOGGER.debug( @@ -221,7 +219,7 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): class ActivityRemote(RemoteInputSelectAccessory): """Generate a Activity Remote accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Activity Remote accessory object.""" super().__init__( RemoteEntityFeature.ACTIVITY, @@ -229,23 +227,25 @@ class ActivityRemote(RemoteInputSelectAccessory): ATTR_ACTIVITY_LIST, *args, ) - self.async_update_state(self.hass.states.get(self.entity_id)) + state = self.hass.states.get(self.entity_id) + assert state + self.async_update_state(state) - def set_on_off(self, value): + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(REMOTE_DOMAIN, service, params) - def set_input_source(self, value): + def set_input_source(self, value: int) -> None: """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) source = self._mapped_sources[self.sources[value]] params = {ATTR_ENTITY_ID: self.entity_id, ATTR_ACTIVITY: source} self.async_call_service(REMOTE_DOMAIN, SERVICE_TURN_ON, params) - def set_remote_key(self, value): + def set_remote_key(self, value: int) -> None: """Send remote key value if call came from HomeKit.""" _LOGGER.debug("%s: Set remote key to %s", self.entity_id, value) if (key_name := REMOTE_KEYS.get(value)) is None: @@ -257,7 +257,7 @@ class ActivityRemote(RemoteInputSelectAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update Television remote state after state changed.""" current_state = new_state.state # Power state remote diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index f9c881339ce..de2c463bfb2 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -1,5 +1,6 @@ """Class to hold all alarm control panel accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_ALARM_SYSTEM @@ -23,7 +24,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( @@ -78,10 +79,11 @@ HK_TO_SERVICE = { class SecuritySystem(HomeAccessory): """Generate an SecuritySystem accessory for an alarm control panel.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a SecuritySystem accessory object.""" super().__init__(*args, category=CATEGORY_ALARM_SYSTEM) state = self.hass.states.get(self.entity_id) + assert state self._alarm_code = self.config.get(ATTR_CODE) supported_states = state.attributes.get( @@ -143,7 +145,7 @@ class SecuritySystem(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def set_security_state(self, value): + def set_security_state(self, value: int) -> None: """Move security state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set security state to %d", self.entity_id, value) service = HK_TO_SERVICE[value] @@ -153,7 +155,7 @@ class SecuritySystem(HomeAccessory): self.async_call_service(DOMAIN, service, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update security state after state changed.""" hass_state = new_state.state if (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None: diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 240cdd888d2..dbf2808a55a 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import NamedTuple +from typing import Any, NamedTuple from pyhap.const import CATEGORY_SENSOR from pyhap.service import Service @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_ON, UnitOfTemperature, ) -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( @@ -112,10 +112,11 @@ class TemperatureSensor(HomeAccessory): Sensor entity must return temperature in °C, °F. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a TemperatureSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_temp = self.add_preload_service(SERV_TEMPERATURE_SENSOR) self.char_temp = serv_temp.configure_char( CHAR_CURRENT_TEMPERATURE, value=0, properties=PROP_CELSIUS @@ -125,7 +126,7 @@ class TemperatureSensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update temperature after state changed.""" unit = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature.CELSIUS @@ -142,10 +143,11 @@ class TemperatureSensor(HomeAccessory): class HumiditySensor(HomeAccessory): """Generate a HumiditySensor accessory as humidity sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a HumiditySensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_humidity = self.add_preload_service(SERV_HUMIDITY_SENSOR) self.char_humidity = serv_humidity.configure_char( CHAR_CURRENT_HUMIDITY, value=0 @@ -155,7 +157,7 @@ class HumiditySensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (humidity := convert_to_float(new_state.state)) is not None: self.char_humidity.set_value(humidity) @@ -166,18 +168,18 @@ class HumiditySensor(HomeAccessory): class AirQualitySensor(HomeAccessory): """Generate a AirQualitySensor accessory as air quality sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a AirQualitySensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) - + assert state self.create_services() # Set the state so it is in sync on initial # GET to avoid an event storm after homekit startup self.async_update_state(state) - def create_services(self): + def create_services(self) -> None: """Initialize a AirQualitySensor accessory object.""" serv_air_quality = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_AIR_PARTICULATE_DENSITY] @@ -188,7 +190,7 @@ class AirQualitySensor(HomeAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (density := convert_to_float(new_state.state)) is not None: if self.char_density.value != density: @@ -203,7 +205,7 @@ class AirQualitySensor(HomeAccessory): class PM10Sensor(AirQualitySensor): """Generate a PM10Sensor accessory as PM 10 sensor.""" - def create_services(self): + def create_services(self) -> None: """Override the init function for PM 10 Sensor.""" serv_air_quality = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_PM10_DENSITY] @@ -212,7 +214,7 @@ class PM10Sensor(AirQualitySensor): self.char_density = serv_air_quality.configure_char(CHAR_PM10_DENSITY, value=0) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" density = convert_to_float(new_state.state) if density is None: @@ -230,7 +232,7 @@ class PM10Sensor(AirQualitySensor): class PM25Sensor(AirQualitySensor): """Generate a PM25Sensor accessory as PM 2.5 sensor.""" - def create_services(self): + def create_services(self) -> None: """Override the init function for PM 2.5 Sensor.""" serv_air_quality = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_PM25_DENSITY] @@ -239,7 +241,7 @@ class PM25Sensor(AirQualitySensor): self.char_density = serv_air_quality.configure_char(CHAR_PM25_DENSITY, value=0) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" density = convert_to_float(new_state.state) if density is None: @@ -257,7 +259,7 @@ class PM25Sensor(AirQualitySensor): class NitrogenDioxideSensor(AirQualitySensor): """Generate a NitrogenDioxideSensor accessory as NO2 sensor.""" - def create_services(self): + def create_services(self) -> None: """Override the init function for PM 2.5 Sensor.""" serv_air_quality = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_NITROGEN_DIOXIDE_DENSITY] @@ -268,7 +270,7 @@ class NitrogenDioxideSensor(AirQualitySensor): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" density = convert_to_float(new_state.state) if density is None: @@ -289,7 +291,7 @@ class VolatileOrganicCompoundsSensor(AirQualitySensor): Sensor entity must return VOC in µg/m3. """ - def create_services(self): + def create_services(self) -> None: """Override the init function for VOC Sensor.""" serv_air_quality: Service = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_VOC_DENSITY] @@ -305,7 +307,7 @@ class VolatileOrganicCompoundsSensor(AirQualitySensor): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" density = convert_to_float(new_state.state) if density is None: @@ -323,10 +325,11 @@ class VolatileOrganicCompoundsSensor(AirQualitySensor): class CarbonMonoxideSensor(HomeAccessory): """Generate a CarbonMonoxidSensor accessory as CO sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a CarbonMonoxideSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_co = self.add_preload_service( SERV_CARBON_MONOXIDE_SENSOR, [CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL], @@ -343,7 +346,7 @@ class CarbonMonoxideSensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (value := convert_to_float(new_state.state)) is not None: self.char_level.set_value(value) @@ -358,10 +361,11 @@ class CarbonMonoxideSensor(HomeAccessory): class CarbonDioxideSensor(HomeAccessory): """Generate a CarbonDioxideSensor accessory as CO2 sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a CarbonDioxideSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_co2 = self.add_preload_service( SERV_CARBON_DIOXIDE_SENSOR, [CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL], @@ -378,7 +382,7 @@ class CarbonDioxideSensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (value := convert_to_float(new_state.state)) is not None: self.char_level.set_value(value) @@ -393,10 +397,11 @@ class CarbonDioxideSensor(HomeAccessory): class LightSensor(HomeAccessory): """Generate a LightSensor accessory as light sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a LightSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_light = self.add_preload_service(SERV_LIGHT_SENSOR) self.char_light = serv_light.configure_char( CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, value=0 @@ -406,7 +411,7 @@ class LightSensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (luminance := convert_to_float(new_state.state)) is not None: self.char_light.set_value(luminance) @@ -417,10 +422,11 @@ class LightSensor(HomeAccessory): class BinarySensor(HomeAccessory): """Generate a BinarySensor accessory as binary sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a BinarySensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state device_class = state.attributes.get(ATTR_DEVICE_CLASS) service_char = ( BINARY_SENSOR_SERVICE_MAP[device_class] @@ -439,7 +445,7 @@ class BinarySensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" state = new_state.state detected = self.format(state in (STATE_ON, STATE_HOME)) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index f79185c64b1..5c0c2c74f0a 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -2,8 +2,9 @@ from __future__ import annotations import logging -from typing import NamedTuple +from typing import Any, NamedTuple +from pyhap.characteristic import Characteristic from pyhap.const import ( CATEGORY_FAUCET, CATEGORY_OUTLET, @@ -30,7 +31,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import callback, split_entity_id +from homeassistant.core import State, callback, split_entity_id from homeassistant.helpers.event import async_call_later from .accessories import TYPES, HomeAccessory @@ -78,10 +79,11 @@ ACTIVATE_ONLY_RESET_SECONDS = 10 class Outlet(HomeAccessory): """Generate an Outlet accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize an Outlet accessory object.""" super().__init__(*args, category=CATEGORY_OUTLET) state = self.hass.states.get(self.entity_id) + assert state serv_outlet = self.add_preload_service(SERV_OUTLET) self.char_on = serv_outlet.configure_char( @@ -94,7 +96,7 @@ class Outlet(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def set_state(self, value): + def set_state(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id} @@ -102,7 +104,7 @@ class Outlet(HomeAccessory): self.async_call_service(DOMAIN, service, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_state = new_state.state == STATE_ON _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) @@ -113,13 +115,14 @@ class Outlet(HomeAccessory): class Switch(HomeAccessory): """Generate a Switch accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) self._domain, self._object_id = split_entity_id(self.entity_id) state = self.hass.states.get(self.entity_id) + assert state - self.activate_only = self.is_activate(self.hass.states.get(self.entity_id)) + self.activate_only = self.is_activate(state) serv_switch = self.add_preload_service(SERV_SWITCH) self.char_on = serv_switch.configure_char( @@ -129,16 +132,16 @@ class Switch(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def is_activate(self, state): + def is_activate(self, state: State) -> bool: """Check if entity is activate only.""" return self._domain in ACTIVATE_ONLY_SWITCH_DOMAINS - def reset_switch(self, *args): + def reset_switch(self, *args: Any) -> None: """Reset switch to emulate activate click.""" _LOGGER.debug("%s: Reset switch to off", self.entity_id) self.char_on.set_value(False) - def set_state(self, value): + def set_state(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) if self.activate_only and not value: @@ -162,7 +165,7 @@ class Switch(HomeAccessory): async_call_later(self.hass, ACTIVATE_ONLY_RESET_SECONDS, self.reset_switch) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" self.activate_only = self.is_activate(new_state) if self.activate_only: @@ -180,10 +183,12 @@ class Switch(HomeAccessory): class Vacuum(Switch): """Generate a Switch accessory.""" - def set_state(self, value): + def set_state(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) state = self.hass.states.get(self.entity_id) + assert state + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if value: @@ -198,7 +203,7 @@ class Vacuum(Switch): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_state = new_state.state in (STATE_CLEANING, STATE_ON) _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) @@ -209,10 +214,12 @@ class Vacuum(Switch): class Valve(HomeAccessory): """Generate a Valve accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Valve accessory object.""" super().__init__(*args) state = self.hass.states.get(self.entity_id) + assert state + valve_type = self.config[CONF_TYPE] self.category = VALVE_TYPE[valve_type].category @@ -228,7 +235,7 @@ class Valve(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def set_state(self, value): + def set_state(self, value: bool) -> None: """Move value state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) self.char_in_use.set_value(value) @@ -237,7 +244,7 @@ class Valve(HomeAccessory): self.async_call_service(DOMAIN, service, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_state = 1 if new_state.state == STATE_ON else 0 _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) @@ -250,12 +257,14 @@ class Valve(HomeAccessory): class SelectSwitch(HomeAccessory): """Generate a Switch accessory that contains multiple switches.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) self.domain = split_entity_id(self.entity_id)[0] state = self.hass.states.get(self.entity_id) - self.select_chars = {} + assert state + + self.select_chars: dict[str, Characteristic] = {} options = state.attributes[ATTR_OPTIONS] for option in options: serv_option = self.add_preload_service( @@ -275,14 +284,14 @@ class SelectSwitch(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def select_option(self, option): + def select_option(self, option: str) -> None: """Set option from HomeKit.""" _LOGGER.debug("%s: Set option to %s", self.entity_id, option) params = {ATTR_ENTITY_ID: self.entity_id, "option": option} self.async_call_service(self.domain, SERVICE_SELECT_OPTION, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_option = cleanup_name_for_homekit(new_state.state) for option, char in self.select_chars.items(): diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index c34e9066160..1fc8b3f2430 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -1,5 +1,6 @@ """Class to hold all thermostat accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_THERMOSTAT @@ -56,6 +57,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import State, callback +from homeassistant.util.enum import try_parse_enum from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -163,22 +165,41 @@ HC_HASS_TO_HOMEKIT_FAN_STATE = { HEAT_COOL_DEADBAND = 5 +def _hk_hvac_mode_from_state(state: State) -> int | None: + """Return the equivalent HomeKit HVAC mode for a given state.""" + if not (hvac_mode := try_parse_enum(HVACMode, state.state)): + _LOGGER.error( + "%s: Received invalid HVAC mode: %s", state.entity_id, state.state + ) + return None + return HC_HASS_TO_HOMEKIT.get(hvac_mode) + + @TYPES.register("Thermostat") class Thermostat(HomeAccessory): """Generate a Thermostat accessory for a climate.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Thermostat accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = self.hass.config.units.temperature_unit - self.hc_homekit_to_hass = None - self.hc_hass_to_homekit = None - hc_min_temp, hc_max_temp = self.get_temperature_range() + state = self.hass.states.get(self.entity_id) + assert state + hc_min_temp, hc_max_temp = self.get_temperature_range(state) + self._reload_on_change_attrs.extend( + ( + ATTR_MIN_HUMIDITY, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_FAN_MODES, + ATTR_HVAC_MODES, + ) + ) # Add additional characteristics if auto mode is supported - self.chars = [] - self.fan_chars = [] - state: State = self.hass.states.get(self.entity_id) + self.chars: list[str] = [] + self.fan_chars: list[str] = [] + attributes = state.attributes min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -276,8 +297,8 @@ class Thermostat(HomeAccessory): CHAR_CURRENT_HUMIDITY, value=50 ) - fan_modes = {} - self.ordered_fan_speeds = [] + fan_modes: dict[str, str] = {} + self.ordered_fan_speeds: list[str] = [] if features & ClimateEntityFeature.FAN_MODE: fan_modes = { @@ -345,17 +366,17 @@ class Thermostat(HomeAccessory): ) self.char_target_fan_state.display_name = "Fan Auto" - self._async_update_state(state) + self.async_update_state(state) serv_thermostat.setter_callback = self._set_chars - def _set_fan_swing_mode(self, swing_on) -> None: + def _set_fan_swing_mode(self, swing_on: int) -> None: _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) mode = self.swing_on_mode if swing_on else SWING_OFF params = {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode} self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_SWING_MODE, params) - def _set_fan_speed(self, speed) -> None: + def _set_fan_speed(self, speed: int) -> None: _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) mode = percentage_to_ordered_list_item(self.ordered_fan_speeds, speed - 1) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} @@ -366,7 +387,7 @@ class Thermostat(HomeAccessory): return percentage_to_ordered_list_item(self.ordered_fan_speeds, 50) return self.fan_modes[FAN_ON] - def _set_fan_active(self, active) -> None: + def _set_fan_active(self, active: int) -> None: _LOGGER.debug("%s: Set fan active to %s", self.entity_id, active) if FAN_OFF not in self.fan_modes: _LOGGER.debug( @@ -379,28 +400,27 @@ class Thermostat(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) - def _set_fan_auto(self, auto) -> None: + def _set_fan_auto(self, auto: int) -> None: _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) - def _temperature_to_homekit(self, temp): + def _temperature_to_homekit(self, temp: float | int) -> float: return temperature_to_homekit(temp, self._unit) - def _temperature_to_states(self, temp): + def _temperature_to_states(self, temp: float | int) -> float: return temperature_to_states(temp, self._unit) - def _set_chars(self, char_values): + def _set_chars(self, char_values: dict[str, Any]) -> None: _LOGGER.debug("Thermostat _set_chars: %s", char_values) events = [] - params = {ATTR_ENTITY_ID: self.entity_id} + params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_id} service = None state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - - hvac_mode = state.state - homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] + homekit_hvac_mode = _hk_hvac_mode_from_state(state) # Homekit will reset the mode when VIEWING the temp # Ignore it if its the same mode if ( @@ -484,10 +504,12 @@ class Thermostat(HomeAccessory): CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values or CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values ): + assert self.char_cooling_thresh_temp + assert self.char_heating_thresh_temp service = SERVICE_SET_TEMPERATURE_THERMOSTAT high = self.char_cooling_thresh_temp.value low = self.char_heating_thresh_temp.value - min_temp, max_temp = self.get_temperature_range() + min_temp, max_temp = self.get_temperature_range(state) if CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values: events.append( f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to" @@ -530,7 +552,7 @@ class Thermostat(HomeAccessory): if CHAR_TARGET_HUMIDITY in char_values: self.set_target_humidity(char_values[CHAR_TARGET_HUMIDITY]) - def _configure_hvac_modes(self, state): + def _configure_hvac_modes(self, state: State) -> None: """Configure target mode characteristics.""" # This cannot be none OR an empty list hc_modes = state.attributes.get(ATTR_HVAC_MODES) or DEFAULT_HVAC_MODES @@ -558,16 +580,16 @@ class Thermostat(HomeAccessory): } self.hc_hass_to_homekit = {k: v for v, k in self.hc_homekit_to_hass.items()} - def get_temperature_range(self): + def get_temperature_range(self, state: State) -> tuple[float, float]: """Return min and max temperature range.""" return _get_temperature_range_from_state( - self.hass.states.get(self.entity_id), + state, self._unit, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, ) - def set_target_humidity(self, value): + def set_target_humidity(self, value: float) -> None: """Set target humidity to value if call came from HomeKit.""" _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: value} @@ -576,38 +598,13 @@ class Thermostat(HomeAccessory): ) @callback - def async_update_state(self, new_state): - """Update thermostat state after state changed.""" - # We always recheck valid hvac modes as the entity - # may not have been fully setup when we saw it last - original_hc_hass_to_homekit = self.hc_hass_to_homekit - self._configure_hvac_modes(new_state) - - if self.hc_hass_to_homekit != original_hc_hass_to_homekit: - if self.char_target_heat_cool.value not in self.hc_homekit_to_hass: - # We must make sure the char value is - # in the new valid values before - # setting the new valid values or - # changing them with throw - self.char_target_heat_cool.set_value( - list(self.hc_homekit_to_hass)[0], should_notify=False - ) - self.char_target_heat_cool.override_properties( - valid_values=self.hc_hass_to_homekit - ) - - self._async_update_state(new_state) - - @callback - def _async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update state without rechecking the device features.""" attributes = new_state.attributes features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Update target operation mode FIRST - hvac_mode = new_state.state - if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: - homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] + if (homekit_hvac_mode := _hk_hvac_mode_from_state(new_state)) is not None: if homekit_hvac_mode in self.hc_homekit_to_hass: self.char_target_heat_cool.set_value(homekit_hvac_mode) else: @@ -616,7 +613,7 @@ class Thermostat(HomeAccessory): "Cannot map hvac target mode: %s to homekit as only %s modes" " are supported" ), - hvac_mode, + new_state.state, self.hc_homekit_to_hass, ) @@ -632,12 +629,14 @@ class Thermostat(HomeAccessory): # Update current humidity if CHAR_CURRENT_HUMIDITY in self.chars: + assert self.char_current_humidity current_humdity = attributes.get(ATTR_CURRENT_HUMIDITY) if isinstance(current_humdity, (int, float)): self.char_current_humidity.set_value(current_humdity) # Update target humidity if CHAR_TARGET_HUMIDITY in self.chars: + assert self.char_target_humidity target_humdity = attributes.get(ATTR_HUMIDITY) if isinstance(target_humdity, (int, float)): self.char_target_humidity.set_value(target_humdity) @@ -685,7 +684,7 @@ class Thermostat(HomeAccessory): self._async_update_fan_state(new_state) @callback - def _async_update_fan_state(self, new_state): + def _async_update_fan_state(self, new_state: State) -> None: """Update state without rechecking the device features.""" attributes = new_state.attributes @@ -724,11 +723,19 @@ class Thermostat(HomeAccessory): class WaterHeater(HomeAccessory): """Generate a WaterHeater accessory for a water_heater.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a WaterHeater accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) + self._reload_on_change_attrs.extend( + ( + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ) + ) self._unit = self.hass.config.units.temperature_unit - min_temp, max_temp = self.get_temperature_range() + state = self.hass.states.get(self.entity_id) + assert state + min_temp, max_temp = self.get_temperature_range(state) serv_thermostat = self.add_preload_service(SERV_THERMOSTAT) @@ -759,25 +766,24 @@ class WaterHeater(HomeAccessory): CHAR_TEMP_DISPLAY_UNITS, value=0 ) - state = self.hass.states.get(self.entity_id) self.async_update_state(state) - def get_temperature_range(self): + def get_temperature_range(self, state: State) -> tuple[float, float]: """Return min and max temperature range.""" return _get_temperature_range_from_state( - self.hass.states.get(self.entity_id), + state, self._unit, DEFAULT_MIN_TEMP_WATER_HEATER, DEFAULT_MAX_TEMP_WATER_HEATER, ) - def set_heat_cool(self, value): + def set_heat_cool(self, value: int) -> None: """Change operation mode to value if call came from HomeKit.""" _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) if HC_HOMEKIT_TO_HASS[value] != HVACMode.HEAT: self.char_target_heat_cool.set_value(1) # Heat - def set_target_temperature(self, value): + def set_target_temperature(self, value: float) -> None: """Set target temperature to value if call came from HomeKit.""" _LOGGER.debug("%s: Set target temperature to %.1f°C", self.entity_id, value) temperature = temperature_to_states(value, self._unit) @@ -790,7 +796,7 @@ class WaterHeater(HomeAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update water_heater state after state change.""" # Update current and target temperature target_temperature = _get_target_temperature(new_state, self._unit) @@ -811,7 +817,9 @@ class WaterHeater(HomeAccessory): self.char_target_heat_cool.set_value(1) # Heat -def _get_temperature_range_from_state(state, unit, default_min, default_max): +def _get_temperature_range_from_state( + state: State, unit: str, default_min: float, default_max: float +) -> tuple[float, float]: """Calculate the temperature range from a state.""" if min_temp := state.attributes.get(ATTR_MIN_TEMP): min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2 @@ -833,7 +841,7 @@ def _get_temperature_range_from_state(state, unit, default_min, default_max): return min_temp, max_temp -def _get_target_temperature(state, unit): +def _get_target_temperature(state: State, unit: str) -> float | None: """Calculate the target temperature from a state.""" target_temp = state.attributes.get(ATTR_TEMPERATURE) if isinstance(target_temp, (int, float)): @@ -841,7 +849,7 @@ def _get_target_temperature(state, unit): return None -def _get_current_temperature(state, unit): +def _get_current_temperature(state: State, unit: str) -> float | None: """Calculate the current temperature from a state.""" target_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) if isinstance(target_temp, (int, float)): diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index ee737e01ff4..8cd01638679 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -6,7 +6,7 @@ from typing import Any from pyhap.const import CATEGORY_SENSOR -from homeassistant.core import CALLBACK_TYPE, Context +from homeassistant.core import CALLBACK_TYPE, Context, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.trigger import async_initialize_triggers @@ -51,7 +51,9 @@ class DeviceTriggerAccessory(HomeAccessory): if (entity_id_or_uuid := trigger.get("entity_id")) and ( entry := ent_reg.async_get(entity_id_or_uuid) ): - unique_id += f"-entity_unique_id:{get_system_unique_id(entry)}" + unique_id += ( + f"-entity_unique_id:{get_system_unique_id(entry, entry.unique_id)}" + ) entity_id = entry.entity_id trigger_name_parts = [] if entity_id and (state := self.hass.states.get(entity_id)): @@ -112,10 +114,12 @@ class DeviceTriggerAccessory(HomeAccessory): _LOGGER.log, ) - async def stop(self) -> None: + @callback + def async_stop(self) -> None: """Handle accessory driver stop event.""" if self._remove_triggers: self._remove_triggers() + super().async_stop() @property def available(self) -> bool: diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 151b97f2cda..8a51f35564e 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -86,8 +86,6 @@ from .const import ( FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, - HOMEKIT_PAIRING_QR, - HOMEKIT_PAIRING_QR_SECRET, MAX_NAME_LENGTH, TYPE_FAUCET, TYPE_OUTLET, @@ -100,6 +98,7 @@ from .const import ( VIDEO_CODEC_H264_V4L2M2M, VIDEO_CODEC_LIBX264, ) +from .models import HomeKitEntryData _LOGGER = logging.getLogger(__name__) @@ -352,8 +351,10 @@ def async_show_setup_message( url.svg(buffer, scale=5, module_color="#000", background="#FFF") pairing_secret = secrets.token_hex(32) - hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR] = buffer.getvalue() - hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET] = pairing_secret + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry_id] + + entry_data.pairing_qr = buffer.getvalue() + entry_data.pairing_qr_secret = pairing_secret message = ( f"To set up {bridge_name} in the Home App, " diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 348dd5e7ccf..ef806cb52bc 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable from datetime import datetime, timedelta +from functools import partial import logging from operator import attrgetter from types import MappingProxyType @@ -28,7 +29,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later, async_track_time_interval from .config_flow import normalize_hkid from .const import ( @@ -42,6 +43,7 @@ from .const import ( IDENTIFIER_LEGACY_SERIAL_NUMBER, IDENTIFIER_SERIAL_NUMBER, STARTUP_EXCEPTIONS, + SUBSCRIBE_COOLDOWN, ) from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry @@ -101,7 +103,7 @@ class HKDevice: # Track aid/iid pairs so we know if we already handle triggers for a HK # service. - self._triggers: list[tuple[int, int]] = [] + self._triggers: set[tuple[int, int]] = set() # A list of callbacks that turn HK characteristics into entities self.char_factories: list[AddCharacteristicCb] = [] @@ -115,7 +117,7 @@ class HKDevice: # This just tracks aid/iid pairs so we know if a HK service has been # mapped to a HA entity. - self.entities: list[tuple[int, int | None, int | None]] = [] + self.entities: set[tuple[int, int | None, int | None]] = set() # A map of aid -> device_id # Useful when routing events to triggers @@ -123,7 +125,7 @@ class HKDevice: self.available = False - self.pollable_characteristics: list[tuple[int, int]] = [] + self.pollable_characteristics: set[tuple[int, int]] = set() # Never allow concurrent polling of the same accessory or bridge self._polling_lock = asyncio.Lock() @@ -133,7 +135,7 @@ class HKDevice: # This is set to True if we can't rely on serial numbers to be unique self.unreliable_serial_numbers = False - self.watchable_characteristics: list[tuple[int, int]] = [] + self.watchable_characteristics: set[tuple[int, int]] = set() self._debounced_update = Debouncer( hass, @@ -144,7 +146,10 @@ class HKDevice: ) self._availability_callbacks: set[CALLBACK_TYPE] = set() + self._config_changed_callbacks: set[CALLBACK_TYPE] = set() self._subscriptions: dict[tuple[int, int], set[CALLBACK_TYPE]] = {} + self._pending_subscribes: set[tuple[int, int]] = set() + self._subscribe_timer: CALLBACK_TYPE | None = None @property def entity_map(self) -> Accessories: @@ -160,26 +165,51 @@ class HKDevice: self, characteristics: list[tuple[int, int]] ) -> None: """Add (aid, iid) pairs that we need to poll.""" - self.pollable_characteristics.extend(characteristics) + self.pollable_characteristics.update(characteristics) - def remove_pollable_characteristics(self, accessory_id: int) -> None: + def remove_pollable_characteristics( + self, characteristics: list[tuple[int, int]] + ) -> None: """Remove all pollable characteristics by accessory id.""" - self.pollable_characteristics = [ - char for char in self.pollable_characteristics if char[0] != accessory_id - ] + for aid_iid in characteristics: + self.pollable_characteristics.discard(aid_iid) - async def add_watchable_characteristics( + def add_watchable_characteristics( self, characteristics: list[tuple[int, int]] ) -> None: """Add (aid, iid) pairs that we need to poll.""" - self.watchable_characteristics.extend(characteristics) - await self.pairing.subscribe(characteristics) + self.watchable_characteristics.update(characteristics) + self._pending_subscribes.update(characteristics) + # Try to subscribe to the characteristics all at once + if not self._subscribe_timer: + self._subscribe_timer = async_call_later( + self.hass, + SUBSCRIBE_COOLDOWN, + self._async_subscribe, + ) - def remove_watchable_characteristics(self, accessory_id: int) -> None: + @callback + def _async_cancel_subscription_timer(self) -> None: + """Cancel the subscribe timer.""" + if self._subscribe_timer: + self._subscribe_timer() + self._subscribe_timer = None + + async def _async_subscribe(self, _now: datetime) -> None: + """Subscribe to characteristics.""" + self._subscribe_timer = None + if self._pending_subscribes: + subscribes = self._pending_subscribes.copy() + self._pending_subscribes.clear() + await self.pairing.subscribe(subscribes) + + def remove_watchable_characteristics( + self, characteristics: list[tuple[int, int]] + ) -> None: """Remove all pollable characteristics by accessory id.""" - self.watchable_characteristics = [ - char for char in self.watchable_characteristics if char[0] != accessory_id - ] + for aid_iid in characteristics: + self.watchable_characteristics.discard(aid_iid) + self._pending_subscribes.discard(aid_iid) @callback def async_set_available_state(self, available: bool) -> None: @@ -262,6 +292,7 @@ class HKDevice: entry.async_on_unload( pairing.dispatcher_availability_changed(self.async_set_available_state) ) + entry.async_on_unload(self._async_cancel_subscription_timer) await self.async_process_entity_map() @@ -406,9 +437,10 @@ class HKDevice: @callback def async_migrate_unique_id( - self, old_unique_id: str, new_unique_id: str, platform: str + self, old_unique_id: str, new_unique_id: str | None, platform: str ) -> None: """Migrate legacy unique IDs to new format.""" + assert new_unique_id is not None _LOGGER.debug( "Checking if unique ID %s on %s needs to be migrated", old_unique_id, @@ -603,23 +635,30 @@ class HKDevice: async def async_update_new_accessories_state(self) -> None: """Process a change in the pairings accessories state.""" await self.async_process_entity_map() - if self.watchable_characteristics: - await self.pairing.subscribe(self.watchable_characteristics) + for callback_ in self._config_changed_callbacks: + callback_() await self.async_update() await self.async_add_new_entities() - def add_accessory_factory(self, add_entities_cb) -> None: + @callback + def async_entity_key_removed(self, entity_key: tuple[int, int | None, int | None]): + """Handle an entity being removed. + + Releases the entity from self.entities so it can be added again. + """ + self.entities.discard(entity_key) + + def add_accessory_factory(self, add_entities_cb: AddAccessoryCb) -> None: """Add a callback to run when discovering new entities for accessories.""" self.accessory_factories.append(add_entities_cb) self._add_new_entities_for_accessory([add_entities_cb]) - def _add_new_entities_for_accessory(self, handlers) -> None: + def _add_new_entities_for_accessory(self, handlers: list[AddAccessoryCb]) -> None: for accessory in self.entity_map.accessories: + entity_key = (accessory.aid, None, None) for handler in handlers: - if (accessory.aid, None, None) in self.entities: - continue - if handler(accessory): - self.entities.append((accessory.aid, None, None)) + if entity_key not in self.entities and handler(accessory): + self.entities.add(entity_key) break def add_char_factory(self, add_entities_cb: AddCharacteristicCb) -> None: @@ -631,11 +670,10 @@ class HKDevice: for accessory in self.entity_map.accessories: for service in accessory.services: for char in service.characteristics: + entity_key = (accessory.aid, service.iid, char.iid) for handler in handlers: - if (accessory.aid, service.iid, char.iid) in self.entities: - continue - if handler(char): - self.entities.append((accessory.aid, service.iid, char.iid)) + if entity_key not in self.entities and handler(char): + self.entities.add(entity_key) break def add_listener(self, add_entities_cb: AddServiceCb) -> None: @@ -661,7 +699,7 @@ class HKDevice: for add_trigger_cb in callbacks: if add_trigger_cb(service): - self._triggers.append(entity_key) + self._triggers.add(entity_key) break def add_entities(self) -> None: @@ -671,19 +709,19 @@ class HKDevice: self._add_new_entities_for_char(self.char_factories) self._add_new_triggers(self.trigger_factories) - def _add_new_entities(self, callbacks) -> None: + def _add_new_entities(self, callbacks: list[AddServiceCb]) -> None: for accessory in self.entity_map.accessories: aid = accessory.aid for service in accessory.services: - iid = service.iid + entity_key = (aid, None, service.iid) - if (aid, None, iid) in self.entities: + if entity_key in self.entities: # Don't add the same entity again continue for listener in callbacks: if listener(service): - self.entities.append((aid, None, iid)) + self.entities.add(entity_key) break async def async_load_platform(self, platform: str) -> None: @@ -795,43 +833,60 @@ class HKDevice: # Process any stateless events (via device_triggers) async_fire_triggers(self, new_values_dict) - self.entity_map.process_changes(new_values_dict) - to_callback: set[CALLBACK_TYPE] = set() - for aid_iid in new_values_dict: + for aid_iid in self.entity_map.process_changes(new_values_dict): if callbacks := self._subscriptions.get(aid_iid): to_callback.update(callbacks) for callback_ in to_callback: callback_() + @callback + def _remove_characteristics_callback( + self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE + ) -> None: + """Remove a characteristics callback.""" + for aid_iid in characteristics: + self._subscriptions[aid_iid].remove(callback_) + if not self._subscriptions[aid_iid]: + del self._subscriptions[aid_iid] + @callback def async_subscribe( - self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE + self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE ) -> CALLBACK_TYPE: """Add characteristics to the watch list.""" for aid_iid in characteristics: self._subscriptions.setdefault(aid_iid, set()).add(callback_) + return partial( + self._remove_characteristics_callback, characteristics, callback_ + ) - def _unsub(): - for aid_iid in characteristics: - self._subscriptions[aid_iid].remove(callback_) - if not self._subscriptions[aid_iid]: - del self._subscriptions[aid_iid] - - return _unsub + @callback + def _remove_availability_callback(self, callback_: CALLBACK_TYPE) -> None: + """Remove an availability callback.""" + self._availability_callbacks.remove(callback_) @callback def async_subscribe_availability(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: """Add characteristics to the watch list.""" self._availability_callbacks.add(callback_) + return partial(self._remove_availability_callback, callback_) - def _unsub(): - self._availability_callbacks.remove(callback_) + @callback + def _remove_config_changed_callback(self, callback_: CALLBACK_TYPE) -> None: + """Remove an availability callback.""" + self._config_changed_callbacks.remove(callback_) - return _unsub + @callback + def async_subscribe_config_changed(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: + """Subscribe to config of the accessory being changed aka c# changes.""" + self._config_changed_callbacks.add(callback_) + return partial(self._remove_config_changed_callback, callback_) - async def get_characteristics(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + async def get_characteristics( + self, *args: Any, **kwargs: Any + ) -> dict[tuple[int, int], dict[str, Any]]: """Read latest state from homekit accessory.""" return await self.pairing.get_characteristics(*args, **kwargs) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index f60dc669968..cc2c28cb5dc 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -120,3 +120,5 @@ STARTUP_EXCEPTIONS = ( # also happens to be the same value used by # the update coordinator. DEBOUNCE_COOLDOWN = 10 # seconds + +SUBSCRIBE_COOLDOWN = 0.25 # seconds diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 0f4af988c14..f94e1145627 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -154,14 +154,9 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): if self.service.has(CharacteristicsTypes.POSITION_HOLD): features |= CoverEntityFeature.STOP - supports_tilt = any( - ( - self.service.has(CharacteristicsTypes.VERTICAL_TILT_CURRENT), - self.service.has(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT), - ) - ) - - if supports_tilt: + if self.service.has( + CharacteristicsTypes.VERTICAL_TILT_CURRENT + ) or self.service.has(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT): features |= ( CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 9eab0fbb098..fa4c1c171c2 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -64,7 +64,8 @@ class TriggerSource: self._callbacks: dict[tuple[str, str], list[Callable[[Any], None]]] = {} self._iid_trigger_keys: dict[int, set[tuple[str, str]]] = {} - async def async_setup( + @callback + def async_setup( self, connection: HKDevice, aid: int, triggers: list[dict[str, Any]] ) -> None: """Set up a set of triggers for a device. @@ -78,7 +79,7 @@ class TriggerSource: self._triggers[trigger_key] = trigger_data iid = trigger_data["characteristic"] self._iid_trigger_keys.setdefault(iid, set()).add(trigger_key) - await connection.add_watchable_characteristics([(aid, iid)]) + connection.add_watchable_characteristics([(aid, iid)]) def fire(self, iid: int, ev: dict[str, Any]) -> None: """Process events that have been received from a HomeKit accessory.""" @@ -237,7 +238,7 @@ async def async_setup_triggers_for_entry( return False trigger = async_get_or_create_trigger_source(conn.hass, device_id) - hass.async_create_task(trigger.async_setup(conn, aid, triggers)) + trigger.async_setup(conn, aid, triggers) return True diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 6fdb450a5b4..d1f48a67e7f 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Any -from aiohomekit.model import Accessory from aiohomekit.model.characteristics import ( EVENT_CHARACTERISTICS, Characteristic, @@ -12,6 +11,7 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import Service, ServicesTypes +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType @@ -24,54 +24,95 @@ class HomeKitEntity(Entity): """Representation of a Home Assistant HomeKit device.""" _attr_should_poll = False + pollable_characteristics: list[tuple[int, int]] + watchable_characteristics: list[tuple[int, int]] + all_characteristics: set[tuple[int, int]] + accessory_info: Service def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: """Initialise a generic HomeKit device.""" self._accessory = accessory - self._aid = devinfo["aid"] - self._iid = devinfo["iid"] + self._aid: int = devinfo["aid"] + self._iid: int = devinfo["iid"] + self._entity_key: tuple[int, int | None, int | None] = ( + self._aid, + None, + self._iid, + ) self._char_name: str | None = None - self.all_characteristics: set[tuple[int, int]] = set() - self.setup() - + self._char_subscription: CALLBACK_TYPE | None = None + self.async_setup() + self._attr_unique_id = f"{accessory.unique_id}_{self._aid}_{self._iid}" super().__init__() - @property - def accessory(self) -> Accessory: - """Return an Accessory model that this entity is attached to.""" - return self._accessory.entity_map.aid(self._aid) + @callback + def _async_handle_entity_removed(self) -> None: + """Handle entity removal.""" + # We call _async_unsubscribe_chars as soon as we + # know the entity is about to be removed so we do not try to + # update characteristics that no longer exist. It will get + # called in async_will_remove_from_hass as well, but that is + # too late. + self._async_unsubscribe_chars() + self.hass.async_create_task(self.async_remove(force_remove=True)) - @property - def accessory_info(self) -> Service: - """Information about the make and model of an accessory.""" - return self.accessory.services.first( - service_type=ServicesTypes.ACCESSORY_INFORMATION - ) + @callback + def _async_remove_entity_if_accessory_or_service_disappeared(self) -> bool: + """Handle accessory or service disappearance.""" + entity_map = self._accessory.entity_map + if not ( + accessory := entity_map.aid_or_none(self._aid) + ) or not accessory.services.iid_or_none(self._iid): + self._async_handle_entity_removed() + return True + return False - @property - def service(self) -> Service: - """Return a Service model that this entity is attached to.""" - return self.accessory.services.iid(self._iid) + @callback + def _async_config_changed(self) -> None: + """Handle accessory discovery changes.""" + if not self._async_remove_entity_if_accessory_or_service_disappeared(): + self._async_reconfigure() + + @callback + def _async_reconfigure(self) -> None: + """Reconfigure the entity.""" + self._async_unsubscribe_chars() + self.async_setup() + self._async_subscribe_chars() + self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Entity added to hass.""" + self._async_subscribe_chars() self.async_on_remove( - self._accessory.async_subscribe( - self.all_characteristics, self._async_write_ha_state - ) + self._accessory.async_subscribe_config_changed(self._async_config_changed) ) self.async_on_remove( self._accessory.async_subscribe_availability(self._async_write_ha_state) ) - self._accessory.add_pollable_characteristics(self.pollable_characteristics) - await self._accessory.add_watchable_characteristics( - self.watchable_characteristics - ) async def async_will_remove_from_hass(self) -> None: """Prepare to be removed from hass.""" - self._accessory.remove_pollable_characteristics(self._aid) - self._accessory.remove_watchable_characteristics(self._aid) + self._async_unsubscribe_chars() + self._accessory.async_entity_key_removed(self._entity_key) + + @callback + def _async_unsubscribe_chars(self): + """Handle unsubscribing from characteristics.""" + if self._char_subscription: + self._char_subscription() + self._char_subscription = None + self._accessory.remove_pollable_characteristics(self.pollable_characteristics) + self._accessory.remove_watchable_characteristics(self.watchable_characteristics) + + @callback + def _async_subscribe_chars(self): + """Handle registering characteristics to watch and subscribe.""" + self._accessory.add_pollable_characteristics(self.pollable_characteristics) + self._accessory.add_watchable_characteristics(self.watchable_characteristics) + self._char_subscription = self._accessory.async_subscribe( + self.all_characteristics, self._async_write_ha_state + ) async def async_put_characteristics(self, characteristics: dict[str, Any]) -> None: """Write characteristics to the device. @@ -90,10 +131,24 @@ class HomeKitEntity(Entity): payload = self.service.build_update(characteristics) return await self._accessory.put_characteristics(payload) - def setup(self) -> None: + @callback + def async_setup(self) -> None: """Configure an entity based on its HomeKit characteristics metadata.""" - self.pollable_characteristics: list[tuple[int, int]] = [] - self.watchable_characteristics: list[tuple[int, int]] = [] + accessory = self._accessory + self.accessory = accessory.entity_map.aid(self._aid) + self.service = self.accessory.services.iid(self._iid) + accessory_info = self.accessory.services.first( + service_type=ServicesTypes.ACCESSORY_INFORMATION + ) + assert accessory_info + self.accessory_info = accessory_info + # If we re-setup, we need to make sure we make new + # lists since we passed them to the connection before + # and we do not want to inadvertently modify the old + # ones. + self.pollable_characteristics = [] + self.watchable_characteristics = [] + self.all_characteristics = set() char_types = self.get_characteristic_types() @@ -136,11 +191,6 @@ class HomeKitEntity(Entity): # Some accessories do not have a serial number return f"homekit-{self._accessory.unique_id}-{self._aid}-{self._iid}" - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_{self._aid}_{self._iid}" - @property def default_name(self) -> str | None: """Return the default name of the device.""" @@ -152,10 +202,9 @@ class HomeKitEntity(Entity): accessory_name = self.accessory.name # If the service has a name char, use that, if not # fallback to the default name provided by the subclass - device_name = self._char_name or self.default_name - folded_device_name = folded_name(device_name or "") - folded_accessory_name = folded_name(accessory_name) - if device_name: + if device_name := self._char_name or self.default_name: + folded_device_name = folded_name(device_name) + folded_accessory_name = folded_name(accessory_name) # Sometimes the device name includes the accessory # name already like My ecobee Occupancy / My ecobee if folded_device_name.startswith(folded_accessory_name): @@ -170,7 +219,11 @@ class HomeKitEntity(Entity): @property def available(self) -> bool: """Return True if entity is available.""" - return self._accessory.available and self.service.available + return self._accessory.available and all( + c.available + for c in self.service.characteristics + if (self._aid, c.iid) in self.all_characteristics + ) @property def device_info(self) -> DeviceInfo: @@ -189,19 +242,19 @@ class HomeKitEntity(Entity): class AccessoryEntity(HomeKitEntity): """A HomeKit entity that is related to an entire accessory rather than a specific service or characteristic.""" + def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: + """Initialise a generic HomeKit accessory.""" + super().__init__(accessory, devinfo) + self._attr_unique_id = f"{accessory.unique_id}_{self._aid}" + @property def old_unique_id(self) -> str: """Return the old ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-aid:{self._aid}" - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_{self._aid}" - -class CharacteristicEntity(HomeKitEntity): +class BaseCharacteristicEntity(HomeKitEntity): """A HomeKit entity that is related to an single characteristic rather than a whole service. This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with @@ -214,14 +267,48 @@ class CharacteristicEntity(HomeKitEntity): """Initialise a generic single characteristic HomeKit entity.""" self._char = char super().__init__(accessory, devinfo) + self._entity_key = (self._aid, self._iid, char.iid) + + @callback + def _async_remove_entity_if_characteristics_disappeared(self) -> bool: + """Handle characteristic disappearance.""" + if ( + not self._accessory.entity_map.aid(self._aid) + .services.iid(self._iid) + .get_char_by_iid(self._char.iid) + ): + self._async_handle_entity_removed() + return True + return False + + @callback + def _async_config_changed(self) -> None: + """Handle accessory discovery changes.""" + if ( + not self._async_remove_entity_if_accessory_or_service_disappeared() + and not self._async_remove_entity_if_characteristics_disappeared() + ): + super()._async_reconfigure() + + +class CharacteristicEntity(BaseCharacteristicEntity): + """A HomeKit entity that is related to an single characteristic rather than a whole service. + + This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with + the service entity. + """ + + def __init__( + self, accessory: HKDevice, devinfo: ConfigType, char: Characteristic + ) -> None: + """Initialise a generic single characteristic HomeKit entity.""" + super().__init__(accessory, devinfo, char) + self._attr_unique_id = ( + f"{accessory.unique_id}_{self._aid}_{char.service.iid}_{char.iid}" + ) @property def old_unique_id(self) -> str: """Return the old ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-aid:{self._aid}-sid:{self._char.service.iid}-cid:{self._char.iid}" - - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_{self._aid}_{self._char.service.iid}_{self._char.iid}" diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py index 9d70127f74a..86046415e35 100644 --- a/homeassistant/components/homekit_controller/event.py +++ b/homeassistant/components/homekit_controller/event.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice -from .entity import HomeKitEntity +from .entity import BaseCharacteristicEntity INPUT_EVENT_VALUES = { InputEventValues.SINGLE_PRESS: "single_press", @@ -26,7 +26,7 @@ INPUT_EVENT_VALUES = { } -class HomeKitEventEntity(HomeKitEntity, EventEntity): +class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): """Representation of a Homekit event entity.""" _attr_should_poll = False @@ -44,10 +44,8 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity): "aid": service.accessory.aid, "iid": service.iid, }, + service.characteristics_by_type[CharacteristicsTypes.INPUT_EVENT], ) - self._characteristic = service.characteristics_by_type[ - CharacteristicsTypes.INPUT_EVENT - ] self.entity_description = entity_description @@ -55,7 +53,7 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity): # clamp InputEventValues for this exact device self._attr_event_types = [ INPUT_EVENT_VALUES[v] - for v in clamp_enum_to_char(InputEventValues, self._characteristic) + for v in clamp_enum_to_char(InputEventValues, self._char) ] def get_characteristic_types(self) -> list[str]: @@ -68,19 +66,19 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity): self.async_on_remove( self._accessory.async_subscribe( - [(self._aid, self._characteristic.iid)], + {(self._aid, self._char.iid)}, self._handle_event, ) ) @callback def _handle_event(self): - if self._characteristic.value is None: + if self._char.value is None: # For IP backed devices the characteristic is marked as # pollable, but always returns None when polled # Make sure we don't explode if we see that edge case. return - self._trigger_event(INPUT_EVENT_VALUES[self._characteristic.value]) + self._trigger_event(INPUT_EVENT_VALUES[self._char.value]) self.async_write_ha_state() diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index cd2cf4022e7..57e4e7e73d8 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES from .connection import HKDevice @@ -152,6 +153,11 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): _attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER _attr_supported_features = HumidifierEntityFeature.MODES + def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: + """Initialise the dehumidifier.""" + super().__init__(accessory, devinfo) + self._attr_unique_id = f"{accessory.unique_id}_{self._iid}_{self.device_class}" + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -260,11 +266,6 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-{self._iid}-{self.device_class}" - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_{self._iid}_{self.device_class}" - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 5687cd4dba3..91fd199e17c 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.5"], + "requirements": ["aiohomekit==3.0.9"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 1efa33429b1..90d1ba754f2 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -159,6 +159,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): characteristics={CharacteristicsTypes.IDENTIFIER: active_identifier}, parent_service=this_tv, ) + assert input_source char = input_source[CharacteristicsTypes.CONFIGURED_NAME] return char.value diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 0f481c5c7ee..2d30de24650 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -581,6 +581,11 @@ class RSSISensor(HomeKitEntity, SensorEntity): _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT _attr_should_poll = False + def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: + """Initialise a HomeKit Controller RSSI sensor.""" + super().__init__(accessory, devinfo) + self._attr_unique_id = f"{accessory.unique_id}_rssi" + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [] @@ -602,11 +607,6 @@ class RSSISensor(HomeKitEntity, SensorEntity): serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-rssi" - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_rssi" - @property def native_value(self) -> int | None: """Return the current rssi value.""" @@ -667,6 +667,7 @@ async def async_setup_entry( accessory_info = accessory.services.first( service_type=ServicesTypes.ACCESSORY_INFORMATION ) + assert accessory_info info = {"aid": accessory.aid, "iid": accessory_info.iid} entity = RSSISensor(conn, info) conn.async_migrate_unique_id( diff --git a/homeassistant/components/homekit_controller/utils.py b/homeassistant/components/homekit_controller/utils.py index b43f1ee05f7..33a08504724 100644 --- a/homeassistant/components/homekit_controller/utils.py +++ b/homeassistant/components/homekit_controller/utils.py @@ -1,4 +1,5 @@ """Helper functions for the homekit_controller component.""" +from functools import lru_cache from typing import cast from aiohomekit import Controller @@ -11,6 +12,7 @@ from .const import CONTROLLER from .storage import async_get_entity_storage +@lru_cache def folded_name(name: str) -> str: """Return a name that is used for matching a similar string.""" return name.casefold().replace(" ", "") diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 96fe1b157f8..19ffb1d6042 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -24,7 +24,7 @@ async def async_setup_entry( class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): """Representation of a identify button.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_category = EntityCategory.CONFIG _attr_device_class = ButtonDeviceClass.IDENTIFY def __init__( diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 82c808a0f13..b24b49da965 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -62,7 +62,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( - title=f"{device_info.product_name} ({device_info.serial})", + title=f"{device_info.product_name}", data=user_input, ) @@ -121,14 +121,18 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors = {"base": ex.error_code} else: return self.async_create_entry( - title=f"{self.discovery.product_name} ({self.discovery.serial})", + title=self.discovery.product_name, data={CONF_IP_ADDRESS: self.discovery.ip}, ) self._set_confirm_only() - self.context["title_placeholders"] = { - "name": f"{self.discovery.product_name} ({self.discovery.serial})" - } + + # We won't be adding mac/serial to the title for devices + # that users generally don't have multiple of. + name = self.discovery.product_name + if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]: + name = f"{name} ({self.discovery.serial})" + self.context["title_placeholders"] = {"name": name} return self.async_show_form( step_id="discovery_confirm", diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index a8f89b67ce9..b8103f7a4cb 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -28,18 +28,23 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - meter_data = { - "device": asdict(coordinator.data.device), - "data": asdict(coordinator.data.data), - "state": asdict(coordinator.data.state) - if coordinator.data.state is not None - else None, - "system": asdict(coordinator.data.system) - if coordinator.data.system is not None - else None, - } + state: dict[str, Any] | None = None + if coordinator.data.state: + state = asdict(coordinator.data.state) - return { - "entry": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(meter_data, TO_REDACT), - } + system: dict[str, Any] | None = None + if coordinator.data.system: + system = asdict(coordinator.data.system) + + return async_redact_data( + { + "entry": async_redact_data(entry.data, TO_REDACT), + "data": { + "device": asdict(coordinator.data.device), + "data": asdict(coordinator.data.data), + "state": state, + "system": system, + }, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 3279c9ba41b..61bf20dbbc4 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -1,8 +1,8 @@ """Base entity for the HomeWizard integration.""" from __future__ import annotations -from homeassistant.const import ATTR_IDENTIFIERS -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -18,13 +18,13 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): """Initialize the HomeWizard entity.""" super().__init__(coordinator=coordinator) self._attr_device_info = DeviceInfo( - name=coordinator.entry.title, manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, model=coordinator.data.device.product_type, ) - if coordinator.data.device.serial is not None: - self._attr_device_info[ATTR_IDENTIFIERS] = { - (DOMAIN, coordinator.data.device.serial) + if (serial_number := coordinator.data.device.serial) is not None: + self._attr_device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, serial_number) } + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)} diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 8930ec90ebf..96507cb26e4 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==2.1.0"], + "requirements": ["python-homewizard-energy==2.1.2"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index d51d180edb1..07f6bb9b55f 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -47,13 +47,17 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) await self.coordinator.async_refresh() + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.state is not None + @property def native_value(self) -> float | None: """Return the current value.""" if ( - self.coordinator.data.state is None - or self.coordinator.data.state.brightness is None + not self.coordinator.data.state + or (brightness := self.coordinator.data.state.brightness) is None ): return None - brightness: float = self.coordinator.data.state.brightness return round(brightness * (100 / 255)) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 63d05135d5d..ab23c878c15 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -38,6 +38,7 @@ from .const import ( CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE, DOMAIN, + RETRY, ) ATTR_FAN_ACTION = "fan_action" @@ -155,6 +156,7 @@ class HoneywellUSThermostat(ClimateEntity): self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp self._away = False + self._retry = 0 self._attr_unique_id = device.deviceid @@ -483,21 +485,28 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._device.refresh() self._attr_available = True + self._retry = 0 + except UnauthorizedError: try: await self._data.client.login() await self._device.refresh() self._attr_available = True + self._retry = 0 except ( SomeComfortError, ClientConnectionError, asyncio.TimeoutError, ): - self._attr_available = False + self._retry += 1 + if self._retry > RETRY: + self._attr_available = False except (ClientConnectionError, asyncio.TimeoutError): - self._attr_available = False + self._retry += 1 + if self._retry > RETRY: + self._attr_available = False except UnexpectedResponse: pass diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py index d5153a69f65..32846563c44 100644 --- a/homeassistant/components/honeywell/const.py +++ b/homeassistant/components/honeywell/const.py @@ -10,3 +10,4 @@ DEFAULT_HEAT_AWAY_TEMPERATURE = 61 CONF_DEV_ID = "thermostat" CONF_LOC_ID = "location" _LOGGER = logging.getLogger(__name__) +RETRY = 3 diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 409b78fb16a..122b7b79ce9 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -445,7 +445,7 @@ class HomeAssistantHTTP: context = ssl_util.server_context_modern() context.load_cert_chain(self.ssl_certificate, self.ssl_key) except OSError as error: - if not self.hass.config.safe_mode: + if not self.hass.config.recovery_mode: raise HomeAssistantError( f"Could not use SSL certificate from {self.ssl_certificate}:" f" {error}" @@ -465,7 +465,7 @@ class HomeAssistantHTTP: context = None else: _LOGGER.critical( - "Home Assistant is running in safe mode with an emergency self" + "Home Assistant is running in recovery mode with an emergency self" " signed ssl certificate because the configured SSL certificate was" " not usable" ) @@ -572,7 +572,7 @@ async def start_http_server_and_save_config( """Startup the http server and save the config.""" await server.start() - # If we are set up successful, we store the HTTP settings for safe mode. + # If we are set up successful, we store the HTTP settings for recovery mode. store: storage.Store[dict[str, Any]] = storage.Store( hass, STORAGE_VERSION, STORAGE_KEY ) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 2d96a4e0426..bf63422ae3a 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -104,7 +104,7 @@ CONNECTION_STATE_ATTRIBUTES = { class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE mobile connection binary sensor.""" - _attr_name: str = field(default="Mobile connection", init=False) + _attr_translation_key: str = field(default="mobile_connection", init=False) _attr_entity_registry_enabled_default = True def __post_init__(self) -> None: @@ -169,7 +169,7 @@ class HuaweiLteBaseWifiStatusBinarySensor(HuaweiLteBaseBinarySensor): class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE WiFi status binary sensor.""" - _attr_name: str = field(default="WiFi status", init=False) + _attr_translation_key: str = field(default="wifi_status", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" @@ -181,7 +181,7 @@ class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): class HuaweiLteWifi24ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 2.4GHz WiFi status binary sensor.""" - _attr_name: str = field(default="2.4GHz WiFi status", init=False) + _attr_translation_key: str = field(default="24ghz_wifi_status", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" @@ -193,7 +193,7 @@ class HuaweiLteWifi24ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): class HuaweiLteWifi5ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 5GHz WiFi status binary sensor.""" - _attr_name: str = field(default="5GHz WiFi status", init=False) + _attr_translation_key: str = field(default="5ghz_wifi_status", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" @@ -205,7 +205,7 @@ class HuaweiLteWifi5ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): class HuaweiLteSmsStorageFullBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE SMS storage full binary sensor.""" - _attr_name: str = field(default="SMS storage full", init=False) + _attr_translation_key: str = field(default="sms_storage_full", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index a4321bfd93f..07486297b32 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -137,7 +137,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "uptime": HuaweiSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, @@ -145,14 +145,14 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "WanIPAddress": HuaweiSensorEntityDescription( key="WanIPAddress", - name="WAN IP address", + translation_key="wan_ip_address", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=True, ), "WanIPv6Address": HuaweiSensorEntityDescription( key="WanIPv6Address", - name="WAN IPv6 address", + translation_key="wan_ipv6_address", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), @@ -165,61 +165,61 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "arfcn": HuaweiSensorEntityDescription( key="arfcn", - name="ARFCN", + translation_key="arfcn", entity_category=EntityCategory.DIAGNOSTIC, ), "band": HuaweiSensorEntityDescription( key="band", - name="Band", + translation_key="band", entity_category=EntityCategory.DIAGNOSTIC, ), "bsic": HuaweiSensorEntityDescription( key="bsic", - name="Base station identity code", + translation_key="base_station_identity_code", entity_category=EntityCategory.DIAGNOSTIC, ), "cell_id": HuaweiSensorEntityDescription( key="cell_id", - name="Cell ID", + translation_key="cell_id", icon="mdi:transmission-tower", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi0": HuaweiSensorEntityDescription( key="cqi0", - name="CQI 0", + translation_key="cqi0", icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi1": HuaweiSensorEntityDescription( key="cqi1", - name="CQI 1", + translation_key="cqi1", icon="mdi:speedometer", ), "dl_mcs": HuaweiSensorEntityDescription( key="dl_mcs", - name="Downlink MCS", + translation_key="downlink_mcs", entity_category=EntityCategory.DIAGNOSTIC, ), "dlbandwidth": HuaweiSensorEntityDescription( key="dlbandwidth", - name="Downlink bandwidth", + translation_key="downlink_bandwidth", icon_fn=lambda x: bandwidth_icon((8, 15), x), entity_category=EntityCategory.DIAGNOSTIC, ), "dlfrequency": HuaweiSensorEntityDescription( key="dlfrequency", - name="Downlink frequency", + translation_key="downlink_frequency", device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, ), "earfcn": HuaweiSensorEntityDescription( key="earfcn", - name="EARFCN", + translation_key="earfcn", entity_category=EntityCategory.DIAGNOSTIC, ), "ecio": HuaweiSensorEntityDescription( key="ecio", - name="EC/IO", + translation_key="ecio", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # https://wiki.teltonika.lt/view/EC/IO icon_fn=lambda x: signal_icon((-20, -10, -6), x), @@ -228,18 +228,18 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "enodeb_id": HuaweiSensorEntityDescription( key="enodeb_id", - name="eNodeB ID", + translation_key="enodeb_id", entity_category=EntityCategory.DIAGNOSTIC, ), "lac": HuaweiSensorEntityDescription( key="lac", - name="LAC", + translation_key="lac", icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "ltedlfreq": HuaweiSensorEntityDescription( key="ltedlfreq", - name="LTE downlink frequency", + translation_key="lte_downlink_frequency", format_fn=format_freq_mhz, suggested_display_precision=0, device_class=SensorDeviceClass.FREQUENCY, @@ -247,7 +247,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "lteulfreq": HuaweiSensorEntityDescription( key="lteulfreq", - name="LTE uplink frequency", + translation_key="lte_uplink_frequency", format_fn=format_freq_mhz, suggested_display_precision=0, device_class=SensorDeviceClass.FREQUENCY, @@ -255,7 +255,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "mode": HuaweiSensorEntityDescription( key="mode", - name="Mode", + translation_key="mode", format_fn=lambda x: ( {"0": "2G", "2": "3G", "7": "4G"}.get(x), None, @@ -271,29 +271,29 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "pci": HuaweiSensorEntityDescription( key="pci", - name="PCI", + translation_key="pci", icon="mdi:transmission-tower", entity_category=EntityCategory.DIAGNOSTIC, ), "plmn": HuaweiSensorEntityDescription( key="plmn", - name="PLMN", + translation_key="plmn", entity_category=EntityCategory.DIAGNOSTIC, ), "rac": HuaweiSensorEntityDescription( key="rac", - name="RAC", + translation_key="rac", icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "rrc_status": HuaweiSensorEntityDescription( key="rrc_status", - name="RRC status", + translation_key="rrc_status", entity_category=EntityCategory.DIAGNOSTIC, ), "rscp": HuaweiSensorEntityDescription( key="rscp", - name="RSCP", + translation_key="rscp", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # https://wiki.teltonika.lt/view/RSCP icon_fn=lambda x: signal_icon((-95, -85, -75), x), @@ -302,7 +302,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "rsrp": HuaweiSensorEntityDescription( key="rsrp", - name="RSRP", + translation_key="rsrp", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/rsrp.php icon_fn=lambda x: signal_icon((-110, -95, -80), x), @@ -312,7 +312,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "rsrq": HuaweiSensorEntityDescription( key="rsrq", - name="RSRQ", + translation_key="rsrq", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/rsrq.php icon_fn=lambda x: signal_icon((-11, -8, -5), x), @@ -322,7 +322,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "rssi": HuaweiSensorEntityDescription( key="rssi", - name="RSSI", + translation_key="rssi", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # https://eyesaas.com/wi-fi-signal-strength/ icon_fn=lambda x: signal_icon((-80, -70, -60), x), @@ -332,7 +332,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "sinr": HuaweiSensorEntityDescription( key="sinr", - name="SINR", + translation_key="sinr", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/sinr.php icon_fn=lambda x: signal_icon((0, 5, 10), x), @@ -342,23 +342,23 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "tac": HuaweiSensorEntityDescription( key="tac", - name="TAC", + translation_key="tac", icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "tdd": HuaweiSensorEntityDescription( key="tdd", - name="TDD", + translation_key="tdd", entity_category=EntityCategory.DIAGNOSTIC, ), "transmode": HuaweiSensorEntityDescription( key="transmode", - name="Transmission mode", + translation_key="transmission_mode", entity_category=EntityCategory.DIAGNOSTIC, ), "txpower": HuaweiSensorEntityDescription( key="txpower", - name="Transmit power", + translation_key="transmit_power", # The value we get from the API tends to consist of several, e.g. # PPusch:15dBm PPucch:2dBm PSrs:42dBm PPrach:1dBm # Present as SIGNAL_STRENGTH only if it was parsed to a number. @@ -372,18 +372,18 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "ul_mcs": HuaweiSensorEntityDescription( key="ul_mcs", - name="Uplink MCS", + translation_key="uplink_mcs", entity_category=EntityCategory.DIAGNOSTIC, ), "ulbandwidth": HuaweiSensorEntityDescription( key="ulbandwidth", - name="Uplink bandwidth", + translation_key="uplink_bandwidth", icon_fn=lambda x: bandwidth_icon((8, 15), x), entity_category=EntityCategory.DIAGNOSTIC, ), "ulfrequency": HuaweiSensorEntityDescription( key="ulfrequency", - name="Uplink frequency", + translation_key="uplink_frequency", device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -399,7 +399,9 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), descriptions={ "UnreadMessage": HuaweiSensorEntityDescription( - key="UnreadMessage", name="SMS unread", icon="mdi:email-arrow-left" + key="UnreadMessage", + translation_key="sms_unread", + icon="mdi:email-arrow-left", ), }, ), @@ -410,7 +412,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "CurrentDayUsed": HuaweiSensorEntityDescription( key="CurrentDayUsed", - name="Current day transfer", + translation_key="current_day_transfer", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:arrow-up-down-bold", @@ -420,7 +422,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentMonthDownload": HuaweiSensorEntityDescription( key="CurrentMonthDownload", - name="Current month download", + translation_key="current_month_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:download", @@ -430,7 +432,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentMonthUpload": HuaweiSensorEntityDescription( key="CurrentMonthUpload", - name="Current month upload", + translation_key="current_month_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:upload", @@ -448,7 +450,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "BatteryPercent": HuaweiSensorEntityDescription( key="BatteryPercent", - name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -456,32 +457,32 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentWifiUser": HuaweiSensorEntityDescription( key="CurrentWifiUser", - name="WiFi clients connected", + translation_key="wifi_clients_connected", icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), "PrimaryDns": HuaweiSensorEntityDescription( key="PrimaryDns", - name="Primary DNS server", + translation_key="primary_dns_server", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "PrimaryIPv6Dns": HuaweiSensorEntityDescription( key="PrimaryIPv6Dns", - name="Primary IPv6 DNS server", + translation_key="primary_ipv6_dns_server", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "SecondaryDns": HuaweiSensorEntityDescription( key="SecondaryDns", - name="Secondary DNS server", + translation_key="secondary_dns_server", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "SecondaryIPv6Dns": HuaweiSensorEntityDescription( key="SecondaryIPv6Dns", - name="Secondary IPv6 DNS server", + translation_key="secondary_ipv6_dns_server", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), @@ -492,14 +493,14 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "CurrentConnectTime": HuaweiSensorEntityDescription( key="CurrentConnectTime", - name="Current connection duration", + translation_key="current_connection_duration", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, icon="mdi:timer-outline", ), "CurrentDownload": HuaweiSensorEntityDescription( key="CurrentDownload", - name="Current connection download", + translation_key="current_connection_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:download", @@ -507,7 +508,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentDownloadRate": HuaweiSensorEntityDescription( key="CurrentDownloadRate", - name="Current download rate", + translation_key="current_download_rate", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -515,7 +516,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentUpload": HuaweiSensorEntityDescription( key="CurrentUpload", - name="Current connection upload", + translation_key="current_connection_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:upload", @@ -523,7 +524,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentUploadRate": HuaweiSensorEntityDescription( key="CurrentUploadRate", - name="Current upload rate", + translation_key="current_upload_rate", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -531,7 +532,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "TotalConnectTime": HuaweiSensorEntityDescription( key="TotalConnectTime", - name="Total connected duration", + translation_key="total_connected_duration", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, icon="mdi:timer-outline", @@ -539,7 +540,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "TotalDownload": HuaweiSensorEntityDescription( key="TotalDownload", - name="Total download", + translation_key="total_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:download", @@ -547,7 +548,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "TotalUpload": HuaweiSensorEntityDescription( key="TotalUpload", - name="Total upload", + translation_key="total_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:upload", @@ -563,17 +564,17 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "FullName": HuaweiSensorEntityDescription( key="FullName", - name="Operator name", + translation_key="operator_name", entity_category=EntityCategory.DIAGNOSTIC, ), "Numeric": HuaweiSensorEntityDescription( key="Numeric", - name="Operator code", + translation_key="operator_code", entity_category=EntityCategory.DIAGNOSTIC, ), "State": HuaweiSensorEntityDescription( key="State", - name="Operator search mode", + translation_key="operator_search_mode", format_fn=lambda x: ( {"0": "Auto", "1": "Manual"}.get(x), None, @@ -587,7 +588,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "NetworkMode": HuaweiSensorEntityDescription( key="NetworkMode", - name="Preferred mode", + translation_key="preferred_mode", format_fn=lambda x: ( { NetworkModeEnum.MODE_AUTO.value: "4G/3G/2G", @@ -611,62 +612,62 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "LocalDeleted": HuaweiSensorEntityDescription( key="LocalDeleted", - name="SMS deleted (device)", + translation_key="sms_deleted_device", icon="mdi:email-minus", ), "LocalDraft": HuaweiSensorEntityDescription( key="LocalDraft", - name="SMS drafts (device)", + translation_key="sms_drafts_device", icon="mdi:email-arrow-right-outline", ), "LocalInbox": HuaweiSensorEntityDescription( key="LocalInbox", - name="SMS inbox (device)", + translation_key="sms_inbox_device", icon="mdi:email", ), "LocalMax": HuaweiSensorEntityDescription( key="LocalMax", - name="SMS capacity (device)", + translation_key="sms_capacity_device", icon="mdi:email", ), "LocalOutbox": HuaweiSensorEntityDescription( key="LocalOutbox", - name="SMS outbox (device)", + translation_key="sms_outbox_device", icon="mdi:email-arrow-right", ), "LocalUnread": HuaweiSensorEntityDescription( key="LocalUnread", - name="SMS unread (device)", + translation_key="sms_unread_device", icon="mdi:email-arrow-left", ), "SimDraft": HuaweiSensorEntityDescription( key="SimDraft", - name="SMS drafts (SIM)", + translation_key="sms_drafts_sim", icon="mdi:email-arrow-right-outline", ), "SimInbox": HuaweiSensorEntityDescription( key="SimInbox", - name="SMS inbox (SIM)", + translation_key="sms_inbox_sim", icon="mdi:email", ), "SimMax": HuaweiSensorEntityDescription( key="SimMax", - name="SMS capacity (SIM)", + translation_key="sms_capacity_sim", icon="mdi:email", ), "SimOutbox": HuaweiSensorEntityDescription( key="SimOutbox", - name="SMS outbox (SIM)", + translation_key="sms_outbox_sim", icon="mdi:email-arrow-right", ), "SimUnread": HuaweiSensorEntityDescription( key="SimUnread", - name="SMS unread (SIM)", + translation_key="sms_unread_sim", icon="mdi:email-arrow-left", ), "SimUsed": HuaweiSensorEntityDescription( key="SimUsed", - name="SMS messages (SIM)", + translation_key="sms_messages_sim", icon="mdi:email-arrow-left", ), }, diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 41826dc6ae7..f188eb9e17b 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -49,6 +49,236 @@ } } }, + "entity": { + "binary_sensor": { + "mobile_connection": { + "name": "Mobile connection" + }, + "wifi_status": { + "name": "Wi-Fi status" + }, + "24ghz_wifi_status": { + "name": "2.4GHz Wi-Fi status" + }, + "5ghz_wifi_status": { + "name": "5GHz Wi-Fi status" + }, + "sms_storage_full": { + "name": "SMS storage full" + } + }, + "sensor": { + "uptime": { + "name": "Uptime" + }, + "wan_ip_address": { + "name": "WAN IP address" + }, + "wan_ipv6_address": { + "name": "WAN IPv6 address" + }, + "arfcn": { + "name": "ARFCN" + }, + "band": { + "name": "Band" + }, + "base_station_identity_code": { + "name": "Base station identity code" + }, + "cell_id": { + "name": "Cell ID" + }, + "cqi0": { + "name": "CQI 0" + }, + "cqi1": { + "name": "CQI 1" + }, + "downlink_mcs": { + "name": "Downlink MCS" + }, + "downlink_bandwidth": { + "name": "Downlink bandwidth" + }, + "downlink_frequency": { + "name": "Downlink frequency" + }, + "earfcn": { + "name": "EARFCN" + }, + "ecio": { + "name": "EC/IO" + }, + "enodeb_id": { + "name": "eNodeB ID" + }, + "lac": { + "name": "LAC" + }, + "lte_downlink_frequency": { + "name": "LTE downlink frequency" + }, + "lte_uplink_frequency": { + "name": "LTE uplink frequency" + }, + "pci": { + "name": "PCI" + }, + "plmn": { + "name": "PLMN" + }, + "rac": { + "name": "RAC" + }, + "rrc_status": { + "name": "RRC status" + }, + "rscp": { + "name": "RSCP" + }, + "rsrp": { + "name": "RSRP" + }, + "rsrq": { + "name": "RSRQ" + }, + "rssi": { + "name": "RSSI" + }, + "sinr": { + "name": "SINR" + }, + "tac": { + "name": "TAC" + }, + "tdd": { + "name": "TDD" + }, + "transmission_mode": { + "name": "Transmission mode" + }, + "transmit_power": { + "name": "Transmit power" + }, + "uplink_mcs": { + "name": "Uplink MCS" + }, + "uplink_bandwidth": { + "name": "Uplink bandwidth" + }, + "uplink_frequency": { + "name": "Uplink frequency" + }, + "sms_unread": { + "name": "SMS unread" + }, + "current_day_transfer": { + "name": "Current day transfer" + }, + "current_month_download": { + "name": "Current month download" + }, + "current_month_upload": { + "name": "Current month upload" + }, + "wifi_clients_connected": { + "name": "Wi-Fi clients connected" + }, + "primary_dns_server": { + "name": "Primary DNS server" + }, + "primary_ipv6_dns_server": { + "name": "Primary IPv6 DNS server" + }, + "secondary_dns_server": { + "name": "Secondary DNS server" + }, + "secondary_ipv6_dns_server": { + "name": "Secondary IPv6 DNS server" + }, + "current_connection_duration": { + "name": "Current connection duration" + }, + "current_connection_download": { + "name": "Current connection download" + }, + "current_download_rate": { + "name": "Current download rate" + }, + "current_connection_upload": { + "name": "Current connection upload" + }, + "current_upload_rate": { + "name": "Current upload rate" + }, + "total_connected_duration": { + "name": "Total connected duration" + }, + "total_download": { + "name": "Total download" + }, + "total_upload": { + "name": "Total upload" + }, + "operator_name": { + "name": "Operator name" + }, + "operator_code": { + "name": "Operator code" + }, + "operator_search_mode": { + "name": "Operator search mode" + }, + "preferred_mode": { + "name": "Preferred mode" + }, + "sms_deleted_device": { + "name": "SMS deleted (device)" + }, + "sms_drafts_device": { + "name": "SMS drafts (device)" + }, + "sms_inbox_device": { + "name": "SMS inbox (device)" + }, + "sms_capacity_device": { + "name": "SMS capacity (device)" + }, + "sms_outbox_device": { + "name": "SMS outbox (device)" + }, + "sms_unread_device": { + "name": "SMS unread (device)" + }, + "sms_drafts_sim": { + "name": "SMS drafts (SIM)" + }, + "sms_inbox_sim": { + "name": "SMS inbox (SIM)" + }, + "sms_capacity_sim": { + "name": "SMS capacity (SIM)" + }, + "sms_outbox_sim": { + "name": "SMS outbox (SIM)" + }, + "sms_unread_sim": { + "name": "SMS unread (SIM)" + }, + "sms_messages_sim": { + "name": "SMS messages (SIM)" + } + }, + "switch": { + "mobile_data": { + "name": "Mobile data" + }, + "wifi_guest_network": { + "name": "Wi-Fi guest network" + } + } + }, "services": { "clear_traffic_statistics": { "name": "Clear traffic statistics", diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index f75cf14e89b..eb9370a946f 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -92,7 +92,7 @@ class HuaweiLteBaseSwitch(HuaweiLteBaseEntityWithDevice, SwitchEntity): class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): """Huawei LTE mobile data switch device.""" - _attr_name: str = field(default="Mobile data", init=False) + _attr_translation_key: str = field(default="mobile_data", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" @@ -124,7 +124,7 @@ class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): class HuaweiLteWifiGuestNetworkSwitch(HuaweiLteBaseSwitch): """Huawei LTE WiFi guest network switch device.""" - _attr_name: str = field(default="WiFi guest network", init=False) + _attr_translation_key: str = field(default="wifi_guest_network", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 914067509b7..da59515e7be 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -71,6 +71,7 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): key="button", device_class=EventDeviceClass.BUTTON, translation_key="button", + has_entity_name=True, ) def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -89,7 +90,8 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): @property def name(self) -> str: """Return name for the entity.""" - return f"{super().name} {self.resource.metadata.control_id}" + # this can be translated too as soon as we support arguments into translations ? + return f"Button {self.resource.metadata.control_id}" @callback def _handle_event(self, event_type: EventType, resource: Button) -> None: @@ -112,6 +114,7 @@ class HueRotaryEventEntity(HueBaseEntity, EventEntity): RelativeRotaryDirection.CLOCK_WISE.value, RelativeRotaryDirection.COUNTER_CLOCK_WISE.value, ], + has_entity_name=True, ) @callback diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index bd290d0bbb8..17f7a81b2a5 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -86,6 +86,8 @@ async def async_setup_entry( class HueSceneEntityBase(HueBaseEntity, SceneEntity): """Base Representation of a Scene entity from Hue Scenes.""" + _attr_has_entity_name = True + def __init__( self, bridge: HueBridge, @@ -97,6 +99,11 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity): self.resource = resource self.controller = controller self.group = self.controller.get_group(self.resource.id) + # we create a virtual service/device for Hue zones/rooms + # so we have a parent for grouped lights and scenes + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.group.id)}, + ) async def async_added_to_hass(self) -> None: """Call when entity is added.""" @@ -112,24 +119,8 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity): @property def name(self) -> str: - """Return default entity name.""" - return f"{self.group.metadata.name} {self.resource.metadata.name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device (service) info.""" - # we create a virtual service/device for Hue scenes - # so we have a parent for grouped lights and scenes - group_type = self.group.type.value.title() - return DeviceInfo( - identifiers={(DOMAIN, self.group.id)}, - entry_type=DeviceEntryType.SERVICE, - name=self.group.metadata.name, - manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name, - model=self.group.type.value.title(), - suggested_area=self.group.metadata.name if group_type == "Room" else None, - via_device=(DOMAIN, self.bridge.api.config.bridge_device.id), - ) + """Return name of the scene.""" + return self.resource.metadata.name class HueSceneEntity(HueSceneEntityBase): diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 1224abb240e..4022c61bc36 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -97,6 +97,7 @@ }, "sensor": { "zigbee_connectivity": { + "name": "Zigbee connectivity", "state": { "connected": "[%key:common::state::connected%]", "disconnected": "[%key:common::state::disconnected%]", @@ -104,6 +105,14 @@ "unidirectional_incoming": "Unidirectional incoming" } } + }, + "switch": { + "motion_sensor_enabled": { + "name": "Motion sensor enabled" + }, + "light_sensor_enabled": { + "name": "Light sensor enabled" + } } }, "options": { diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index 31b5de3a9a1..c9da30a779c 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -1,9 +1,10 @@ """Support for switch platform for Hue resources (V2 only).""" from __future__ import annotations -from typing import Any, TypeAlias +from typing import Any from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.config import BehaviorInstance, BehaviorInstanceController from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.sensors import ( LightLevel, @@ -12,7 +13,11 @@ from aiohue.v2.controllers.sensors import ( MotionController, ) -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -22,10 +27,6 @@ from .bridge import HueBridge from .const import DOMAIN from .v2.entity import HueBaseEntity -ControllerType: TypeAlias = LightLevelController | MotionController - -SensingService: TypeAlias = LightLevel | Motion - async def async_setup_entry( hass: HomeAssistant, @@ -41,13 +42,22 @@ async def async_setup_entry( raise NotImplementedError("Switch support is only available for V2 bridges") @callback - def register_items(controller: ControllerType): + def register_items( + controller: BehaviorInstanceController + | LightLevelController + | MotionController, + switch_class: type[ + HueBehaviorInstanceEnabledEntity + | HueLightSensorEnabledEntity + | HueMotionSensorEnabledEntity + ], + ): @callback - def async_add_entity(event_type: EventType, resource: SensingService) -> None: + def async_add_entity( + event_type: EventType, resource: BehaviorInstance | LightLevel | Motion + ) -> None: """Add entity from Hue resource.""" - async_add_entities( - [HueSensingServiceEnabledEntity(bridge, controller, resource)] - ) + async_add_entities([switch_class(bridge, api.sensors.motion, resource)]) # add all current items in controller for item in controller: @@ -61,26 +71,23 @@ async def async_setup_entry( ) # setup for each switch-type hue resource - register_items(api.sensors.motion) - register_items(api.sensors.light_level) + register_items(api.sensors.motion, HueMotionSensorEnabledEntity) + register_items(api.sensors.light_level, HueLightSensorEnabledEntity) + register_items(api.config.behavior_instance, HueBehaviorInstanceEnabledEntity) -class HueSensingServiceEnabledEntity(HueBaseEntity, SwitchEntity): - """Representation of a Switch entity from Hue SensingService.""" +class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity): + """Representation of a Switch entity from a Hue resource that can be toggled enabled.""" - _attr_entity_category = EntityCategory.CONFIG - _attr_device_class = SwitchDeviceClass.SWITCH + controller: BehaviorInstanceController | LightLevelController | MotionController + resource: BehaviorInstance | LightLevel | Motion - def __init__( - self, - bridge: HueBridge, - controller: LightLevelController | MotionController, - resource: SensingService, - ) -> None: - """Initialize the entity.""" - super().__init__(bridge, controller, resource) - self.resource = resource - self.controller = controller + entity_description = SwitchEntityDescription( + key="sensing_service_enabled", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + ) @property def is_on(self) -> bool: @@ -98,3 +105,45 @@ class HueSensingServiceEnabledEntity(HueBaseEntity, SwitchEntity): await self.bridge.async_request_call( self.controller.set_enabled, self.resource.id, enabled=False ) + + +class HueBehaviorInstanceEnabledEntity(HueResourceEnabledEntity): + """Representation of a Switch entity to enable/disable a Hue Behavior Instance.""" + + resource: BehaviorInstance + + entity_description = SwitchEntityDescription( + key="behavior_instance", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + ) + + @property + def name(self) -> str: + """Return name for this entity.""" + return f"Automation: {self.resource.metadata.name}" + + +class HueMotionSensorEnabledEntity(HueResourceEnabledEntity): + """Representation of a Switch entity to enable/disable a Hue motion sensor.""" + + entity_description = SwitchEntityDescription( + key="motion_sensor_enabled", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + translation_key="motion_sensor_enabled", + ) + + +class HueLightSensorEnabledEntity(HueResourceEnabledEntity): + """Representation of a Switch entity to enable/disable a Hue light sensor.""" + + entity_description = SwitchEntityDescription( + key="light_sensor_enabled", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + translation_key="light_sensor_enabled", + ) diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 1eded0429b8..f1bcd0bbbe3 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -24,8 +24,10 @@ from aiohue.v2.models.tamper import Tamper, TamperState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -80,25 +82,17 @@ async def async_setup_entry( register_items(api.sensors.tamper, HueTamperSensor) -class HueBinarySensorBase(HueBaseEntity, BinarySensorEntity): - """Representation of a Hue binary_sensor.""" - - def __init__( - self, - bridge: HueBridge, - controller: ControllerType, - resource: SensorType, - ) -> None: - """Initialize the binary sensor.""" - super().__init__(bridge, controller, resource) - self.resource = resource - self.controller = controller - - -class HueMotionSensor(HueBinarySensorBase): +class HueMotionSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Motion sensor.""" - _attr_device_class = BinarySensorDeviceClass.MOTION + controller: CameraMotionController | MotionController + resource: CameraMotion | Motion + + entity_description = BinarySensorEntityDescription( + key="motion_sensor", + device_class=BinarySensorDeviceClass.MOTION, + has_entity_name=True, + ) @property def is_on(self) -> bool | None: @@ -109,10 +103,17 @@ class HueMotionSensor(HueBinarySensorBase): return self.resource.motion.value -class HueEntertainmentActiveSensor(HueBinarySensorBase): +class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Entertainment Configuration as binary sensor.""" - _attr_device_class = BinarySensorDeviceClass.RUNNING + controller: EntertainmentConfigurationController + resource: EntertainmentConfiguration + + entity_description = BinarySensorEntityDescription( + key="entertainment_active_sensor", + device_class=BinarySensorDeviceClass.RUNNING, + has_entity_name=False, + ) @property def is_on(self) -> bool | None: @@ -122,14 +123,20 @@ class HueEntertainmentActiveSensor(HueBinarySensorBase): @property def name(self) -> str: """Return sensor name.""" - type_title = self.resource.type.value.replace("_", " ").title() - return f"{self.resource.metadata.name}: {type_title}" + return self.resource.metadata.name -class HueContactSensor(HueBinarySensorBase): +class HueContactSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Contact sensor.""" - _attr_device_class = BinarySensorDeviceClass.OPENING + controller: ContactController + resource: Contact + + entity_description = BinarySensorEntityDescription( + key="contact_sensor", + device_class=BinarySensorDeviceClass.OPENING, + has_entity_name=True, + ) @property def is_on(self) -> bool | None: @@ -140,10 +147,18 @@ class HueContactSensor(HueBinarySensorBase): return self.resource.contact_report.state != ContactState.CONTACT -class HueTamperSensor(HueBinarySensorBase): +class HueTamperSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Tamper sensor.""" - _attr_device_class = BinarySensorDeviceClass.TAMPER + controller: TamperController + resource: Tamper + + entity_description = BinarySensorEntityDescription( + key="tamper_sensor", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + ) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 6fed4bc16d1..75f474cc0ea 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -3,7 +3,9 @@ from typing import TYPE_CHECKING from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.groups import Room, Zone from aiohue.v2.models.device import Device, DeviceArchetypes +from aiohue.v2.models.resource import ResourceTypes from homeassistant.const import ( ATTR_CONNECTIONS, @@ -33,23 +35,38 @@ async def async_setup_devices(bridge: "HueBridge"): dev_controller = api.devices @callback - def add_device(hue_device: Device) -> dr.DeviceEntry: + def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry: """Register a Hue device in device registry.""" - model = f"{hue_device.product_data.product_name} ({hue_device.product_data.model_id})" + if isinstance(hue_resource, (Room, Zone)): + # Register a Hue Room/Zone as service in HA device registry. + return dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN, hue_resource.id)}, + name=hue_resource.metadata.name, + model=hue_resource.type.value.title(), + manufacturer=api.config.bridge_device.product_data.manufacturer_name, + via_device=(DOMAIN, api.config.bridge_device.id), + suggested_area=hue_resource.metadata.name + if hue_resource.type == ResourceTypes.ROOM + else None, + ) + # Register a Hue device resource as device in HA device registry. + model = f"{hue_resource.product_data.product_name} ({hue_resource.product_data.model_id})" params = { - ATTR_IDENTIFIERS: {(DOMAIN, hue_device.id)}, - ATTR_SW_VERSION: hue_device.product_data.software_version, - ATTR_NAME: hue_device.metadata.name, + ATTR_IDENTIFIERS: {(DOMAIN, hue_resource.id)}, + ATTR_SW_VERSION: hue_resource.product_data.software_version, + ATTR_NAME: hue_resource.metadata.name, ATTR_MODEL: model, - ATTR_MANUFACTURER: hue_device.product_data.manufacturer_name, + ATTR_MANUFACTURER: hue_resource.product_data.manufacturer_name, } - if room := dev_controller.get_room(hue_device.id): + if room := dev_controller.get_room(hue_resource.id): params[ATTR_SUGGESTED_AREA] = room.metadata.name - if hue_device.metadata.archetype == DeviceArchetypes.BRIDGE_V2: + if hue_resource.metadata.archetype == DeviceArchetypes.BRIDGE_V2: params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id)) else: params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) - zigbee = dev_controller.get_zigbee_connectivity(hue_device.id) + zigbee = dev_controller.get_zigbee_connectivity(hue_resource.id) if zigbee and zigbee.mac_address: params[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, zigbee.mac_address)} @@ -63,25 +80,27 @@ async def async_setup_devices(bridge: "HueBridge"): dev_reg.async_remove_device(device.id) @callback - def handle_device_event(evt_type: EventType, hue_device: Device) -> None: - """Handle event from Hue devices controller.""" + def handle_device_event( + evt_type: EventType, hue_resource: Device | Room | Zone + ) -> None: + """Handle event from Hue controller.""" if evt_type == EventType.RESOURCE_DELETED: - remove_device(hue_device.id) + remove_device(hue_resource.id) else: # updates to existing device will also be handled by this call - add_device(hue_device) + add_device(hue_resource) - # create/update all current devices found in controller + # create/update all current devices found in controllers known_devices = [add_device(hue_device) for hue_device in dev_controller] + known_devices += [add_device(hue_room) for hue_room in api.groups.room] + known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] # Check for nodes that no longer exist and remove them for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): if device not in known_devices: - # handle case where a virtual device was created for a Hue group - hue_dev_id = next(x[1] for x in device.identifiers if x[0] == DOMAIN) - if hue_dev_id in api.groups: - continue dev_reg.async_remove_device(device.id) - # add listener for updates on Hue devices controller + # add listener for updates on Hue controllers entry.async_on_unload(dev_controller.subscribe(handle_device_event)) + entry.async_on_unload(api.groups.room.subscribe(handle_device_event)) + entry.async_on_unload(api.groups.zone.subscribe(handle_device_event)) diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index f4c76618009..75e4bb1edd4 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -9,10 +9,7 @@ from aiohue.v2.models.resource import ResourceTypes from aiohue.v2.models.zigbee_connectivity import ConnectivityServiceStatus from homeassistant.core import callback -from homeassistant.helpers.device_registry import ( - DeviceInfo, - async_get as async_get_device_registry, -) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry @@ -72,24 +69,6 @@ class HueBaseEntity(Entity): self._ignore_availability = None self._last_state = None - @property - def name(self) -> str: - """Return name for the entity.""" - if self.device is None: - # this is just a guard - # creating a pretty name for device-less entities (e.g. groups/scenes) - # should be handled in the platform instead - return self.resource.type.value - dev_name = self.device.metadata.name - # if resource is a light, use the device name itself - if self.resource.type == ResourceTypes.LIGHT: - return dev_name - # for sensors etc, use devicename + pretty name of type - type_title = RESOURCE_TYPE_NAMES.get( - self.resource.type, self.resource.type.value.replace("_", " ").title() - ) - return f"{dev_name} {type_title}" - async def async_added_to_hass(self) -> None: """Call when entity is added.""" self._check_availability() @@ -146,19 +125,12 @@ class HueBaseEntity(Entity): def _handle_event(self, event_type: EventType, resource: HueResource) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_DELETED: - # handle removal of room and zone 'virtual' devices/services - # regular devices are removed automatically by the logic in device.py. - if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE): - dev_reg = async_get_device_registry(self.hass) - if device := dev_reg.async_get_device( - identifiers={(DOMAIN, resource.id)} - ): - dev_reg.async_remove_device(device.id) # cleanup entities that are not strictly device-bound and have the bridge as parent - if self.device is None: + if self.device is None and resource.id == self.resource.id: ent_reg = async_get_entity_registry(self.hass) ent_reg.async_remove(self.entity_id) return + self.logger.debug("Received status update for %s", self.entity_id) self._check_availability() self.on_update() diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 9985d37627b..8ce6d287551 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -1,6 +1,7 @@ """Support for Hue groups (room/zone).""" from __future__ import annotations +import asyncio from typing import Any from aiohue.v2 import HueBridgeV2 @@ -17,11 +18,12 @@ from homeassistant.components.light import ( FLASH_SHORT, ColorMode, LightEntity, + LightEntityDescription, LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from ..bridge import HueBridge @@ -43,18 +45,26 @@ async def async_setup_entry( bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] api: HueBridgeV2 = bridge.api - @callback - def async_add_light(event_type: EventType, resource: GroupedLight) -> None: + async def async_add_light(event_type: EventType, resource: GroupedLight) -> None: """Add Grouped Light for Hue Room/Zone.""" - group = api.groups.grouped_light.get_zone(resource.id) + # delay group creation a bit due to a race condition where the + # grouped_light resource is created before the zone/room + retries = 5 + while ( + retries + and (group := api.groups.grouped_light.get_zone(resource.id)) is None + ): + retries -= 1 + await asyncio.sleep(0.5) if group is None: + # guard, just in case return light = GroupedHueLight(bridge, resource, group) async_add_entities([light]) # add current items for item in api.groups.grouped_light.items: - async_add_light(EventType.RESOURCE_ADDED, item) + await async_add_light(EventType.RESOURCE_ADDED, item) # register listener for new grouped_light config_entry.async_on_unload( @@ -67,7 +77,12 @@ async def async_setup_entry( class GroupedHueLight(HueBaseEntity, LightEntity): """Representation of a Grouped Hue light.""" - _attr_icon = "mdi:lightbulb-group" + entity_description = LightEntityDescription( + key="hue_grouped_light", + icon="mdi:lightbulb-group", + has_entity_name=True, + name=None, + ) def __init__( self, bridge: HueBridge, resource: GroupedLight, group: Room | Zone @@ -81,7 +96,13 @@ class GroupedHueLight(HueBaseEntity, LightEntity): self.api: HueBridgeV2 = bridge.api self._attr_supported_features |= LightEntityFeature.FLASH self._attr_supported_features |= LightEntityFeature.TRANSITION - + self._restore_brightness: float | None = None + self._brightness_pct: float = 0 + # we create a virtual service/device for Hue zones/rooms + # so we have a parent for grouped lights and scenes + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.group.id)}, + ) self._dynamic_mode_active = False self._update_values() @@ -103,11 +124,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity): self.api.lights.subscribe(self._handle_event, light_ids) ) - @property - def name(self) -> str: - """Return name of room/zone for this grouped light.""" - return self.group.metadata.name - @property def is_on(self) -> bool: """Return true if light is on.""" @@ -131,22 +147,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity): "dynamics": self._dynamic_mode_active, } - @property - def device_info(self) -> DeviceInfo: - """Return device (service) info.""" - # we create a virtual service/device for Hue zones/rooms - # so we have a parent for grouped lights and scenes - model = self.group.type.value.title() - return DeviceInfo( - identifiers={(DOMAIN, self.group.id)}, - entry_type=DeviceEntryType.SERVICE, - name=self.group.metadata.name, - manufacturer=self.api.config.bridge_device.product_data.manufacturer_name, - model=model, - suggested_area=self.group.metadata.name if model == "Room" else None, - via_device=(DOMAIN, self.api.config.bridge_device.id), - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the grouped_light on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) @@ -155,6 +155,18 @@ class GroupedHueLight(HueBaseEntity, LightEntity): brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) + if self._restore_brightness and brightness is None: + # The Hue bridge sets the brightness to 1% when turning on a bulb + # when a transition was used to turn off the bulb. + # This issue has been reported on the Hue forum several times: + # https://developers.meethue.com/forum/t/brightness-turns-down-to-1-automatically-shortly-after-sending-off-signal-hue-bug/5692 + # https://developers.meethue.com/forum/t/lights-turn-on-with-lowest-brightness-via-siri-if-turned-off-via-api/6700 + # https://developers.meethue.com/forum/t/using-transitiontime-with-on-false-resets-bri-to-1/4585 + # https://developers.meethue.com/forum/t/bri-value-changing-in-switching-lights-on-off/6323 + # https://developers.meethue.com/forum/t/fade-in-fade-out/6673 + brightness = self._restore_brightness + self._restore_brightness = None + if flash is not None: await self.async_set_flash(flash) return @@ -172,6 +184,8 @@ class GroupedHueLight(HueBaseEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) + if transition is not None: + self._restore_brightness = self._brightness_pct flash = kwargs.get(ATTR_FLASH) if flash is not None: @@ -246,6 +260,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity): if len(supported_color_modes) == 0: # only add color mode brightness if no color variants supported_color_modes.add(ColorMode.BRIGHTNESS) + self._brightness_pct = total_brightness / lights_with_dimming_support self._attr_brightness = round( ((total_brightness / lights_with_dimming_support) / 100) * 255 ) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index f42da406599..348d60d8de2 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -19,6 +19,7 @@ from homeassistant.components.light import ( FLASH_SHORT, ColorMode, LightEntity, + LightEntityDescription, LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -69,6 +70,10 @@ async def async_setup_entry( class HueLight(HueBaseEntity, LightEntity): """Representation of a Hue light.""" + entity_description = LightEntityDescription( + key="hue_light", has_entity_name=True, name=None + ) + def __init__( self, bridge: HueBridge, controller: LightsController, resource: Light ) -> None: @@ -89,6 +94,7 @@ class HueLight(HueBaseEntity, LightEntity): self._supported_color_modes.add(ColorMode.BRIGHTNESS) # support transition if brightness control self._attr_supported_features |= LightEntityFeature.TRANSITION + self._last_brightness: float | None = None self._color_temp_active: bool = False # get list of supported effects (combine effects and timed_effects) self._attr_effect_list = [] @@ -204,6 +210,17 @@ class HueLight(HueBaseEntity, LightEntity): xy_color = kwargs.get(ATTR_XY_COLOR) color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP)) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) + if self._last_brightness and brightness is None: + # The Hue bridge sets the brightness to 1% when turning on a bulb + # when a transition was used to turn off the bulb. + # This issue has been reported on the Hue forum several times: + # https://developers.meethue.com/forum/t/brightness-turns-down-to-1-automatically-shortly-after-sending-off-signal-hue-bug/5692 + # https://developers.meethue.com/forum/t/lights-turn-on-with-lowest-brightness-via-siri-if-turned-off-via-api/6700 + # https://developers.meethue.com/forum/t/using-transitiontime-with-on-false-resets-bri-to-1/4585 + # https://developers.meethue.com/forum/t/bri-value-changing-in-switching-lights-on-off/6323 + # https://developers.meethue.com/forum/t/fade-in-fade-out/6673 + brightness = self._last_brightness + self._last_brightness = None self._color_temp_active = color_temp is not None flash = kwargs.get(ATTR_FLASH) effect = effect_str = kwargs.get(ATTR_EFFECT) @@ -240,6 +257,8 @@ class HueLight(HueBaseEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) + if transition is not None and self.resource.dimming: + self._last_brightness = self.resource.dimming.brightness flash = kwargs.get(ATTR_FLASH) if flash is not None: diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 4bfb727b917..56f708e2dfd 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -20,6 +20,7 @@ from aiohue.v2.models.zigbee_connectivity import ZigbeeConnectivity from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -93,9 +94,13 @@ class HueSensorBase(HueBaseEntity, SensorEntity): class HueTemperatureSensor(HueSensorBase): """Representation of a Hue Temperature sensor.""" - _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_state_class = SensorStateClass.MEASUREMENT + entity_description = SensorEntityDescription( + key="temperature_sensor", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, + ) @property def native_value(self) -> float: @@ -106,9 +111,13 @@ class HueTemperatureSensor(HueSensorBase): class HueLightLevelSensor(HueSensorBase): """Representation of a Hue LightLevel (illuminance) sensor.""" - _attr_native_unit_of_measurement = LIGHT_LUX - _attr_device_class = SensorDeviceClass.ILLUMINANCE - _attr_state_class = SensorStateClass.MEASUREMENT + entity_description = SensorEntityDescription( + key="lightlevel_sensor", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, + ) @property def native_value(self) -> int: @@ -130,10 +139,14 @@ class HueLightLevelSensor(HueSensorBase): class HueBatterySensor(HueSensorBase): """Representation of a Hue Battery sensor.""" - _attr_native_unit_of_measurement = PERCENTAGE - _attr_device_class = SensorDeviceClass.BATTERY - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_state_class = SensorStateClass.MEASUREMENT + entity_description = SensorEntityDescription( + key="battery_sensor", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) @property def native_value(self) -> int: @@ -151,16 +164,20 @@ class HueBatterySensor(HueSensorBase): class HueZigbeeConnectivitySensor(HueSensorBase): """Representation of a Hue ZigbeeConnectivity sensor.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_translation_key = "zigbee_connectivity" - _attr_device_class = SensorDeviceClass.ENUM - _attr_options = [ - "connected", - "disconnected", - "connectivity_issue", - "unidirectional_incoming", - ] - _attr_entity_registry_enabled_default = False + entity_description = SensorEntityDescription( + key="zigbee_connectivity_sensor", + device_class=SensorDeviceClass.ENUM, + has_entity_name=True, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="zigbee_connectivity", + options=[ + "connected", + "disconnected", + "connectivity_issue", + "unidirectional_incoming", + ], + entity_registry_enabled_default=False, + ) @property def native_value(self) -> str: diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 8bc86d423a1..b82b2b34a4b 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -146,7 +146,7 @@ SENSORS_INFO = [ translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_DAY, precision=1, @@ -156,7 +156,7 @@ SENSORS_INFO = [ translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_WEEK, precision=1, @@ -166,7 +166,7 @@ SENSORS_INFO = [ translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_MONTH, precision=1, @@ -176,7 +176,7 @@ SENSORS_INFO = [ translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_YEAR, precision=1, @@ -197,7 +197,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_DAY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -207,7 +207,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_WEEK, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -217,7 +217,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_MONTH, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -227,7 +227,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_YEAR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 1cdad10f2fb..cb59dd04bdd 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -72,7 +72,7 @@ "name": "Dehumidifier" }, "humidifier": { - "name": "[%key:component::humidifier::entity_component::_::name%]" + "name": "[%key:component::humidifier::title%]" } }, "services": { diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 4cbd0a83321..24fb9c32a7d 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -150,7 +150,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): try: departure_list = await hub.gti.departureList( { - "station": self.config_entry.data[CONF_STATION], + "station": { + "type": "STATION", + "id": self.config_entry.data[CONF_STATION].get("id"), + }, "time": {"date": "heute", "time": "jetzt"}, "maxList": 5, "maxTimeOffset": 200, diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index bc3c62cfb9f..ddff1954eb3 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -2,7 +2,6 @@ from pydrawise import legacy -from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -13,11 +12,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL from .coordinator import HydrawiseDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( @@ -53,24 +51,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hydrawise from a config entry.""" access_token = config_entry.data[CONF_API_KEY] - try: - hydrawise = await hass.async_add_executor_job( - legacy.LegacyHydrawise, access_token - ) - except (ConnectTimeout, HTTPError) as ex: - LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) - raise ConfigEntryNotReady( - f"Unable to connect to Hydrawise cloud service: {ex}" - ) from ex - - hass.data.setdefault(DOMAIN, {})[ - config_entry.entry_id - ] = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) - if not hydrawise.controller_info or not hydrawise.controller_status: - raise ConfigEntryNotReady("Hydrawise data not loaded") - - # NOTE: We don't need to call async_config_entry_first_refresh() because - # data is fetched when the Hydrawiser object is instantiated. + hydrawise = legacy.LegacyHydrawise(access_token, load_on_init=False) + coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 1c40b16926d..1953e413672 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -12,12 +12,12 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -57,7 +57,7 @@ def setup_platform( ) -> None: """Set up a sensor for a Hydrawise device.""" # We don't need to trigger import flow from here as it's triggered from `__init__.py` - return + return # pragma: no cover async def async_setup_entry( @@ -95,13 +95,10 @@ async def async_setup_entry( class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" - @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and updates the state.""" - LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) + def _update_attrs(self) -> None: + """Update state attributes.""" if self.entity_description.key == "status": self._attr_is_on = self.coordinator.last_update_success elif self.entity_description.key == "is_watering": relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] self._attr_is_on = relay_data["timestr"] == "Now" - super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index c3f295e1c4d..38fde322673 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,3 +37,14 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): name=data["name"], manufacturer=MANUFACTURER, ) + self._update_attrs() + + def _update_attrs(self) -> None: + """Update state attributes.""" + return # pragma: no cover + + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and updates the state.""" + self._update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index eea4a0e2ebf..4e73a2ba64c 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2023.8.0"] + "requirements": ["pydrawise==2023.10.0"] } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index a5bd9251a33..369e952c1be 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -11,13 +11,13 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -59,7 +59,7 @@ def setup_platform( ) -> None: """Set up a sensor for a Hydrawise device.""" # We don't need to trigger import flow from here as it's triggered from `__init__.py` - return + return # pragma: no cover async def async_setup_entry( @@ -82,10 +82,8 @@ async def async_setup_entry( class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and updates the states.""" - LOGGER.debug("Updating Hydrawise sensor: %s", self.name) + def _update_attrs(self) -> None: + """Update state attributes.""" relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": @@ -94,8 +92,6 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): self._attr_native_value = 0 else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) - LOGGER.debug("New cycle time: %s", next_cycle) self._attr_native_value = dt_util.utc_from_timestamp( dt_util.as_timestamp(dt_util.now()) + next_cycle ) - super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 8cdb5b67561..caaefd7aa26 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -23,7 +23,6 @@ from .const import ( CONF_WATERING_TIME, DEFAULT_WATERING_TIME, DOMAIN, - LOGGER, ) from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -65,7 +64,7 @@ def setup_platform( ) -> None: """Set up a sensor for a Hydrawise device.""" # We don't need to trigger import flow from here as it's triggered from `__init__.py` - return + return # pragma: no cover async def async_setup_entry( @@ -124,14 +123,11 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): elif self.entity_description.key == "auto_watering": self.coordinator.api.suspend_zone(365, zone_number) - @callback - def _handle_coordinator_update(self) -> None: - """Update device state.""" + def _update_attrs(self) -> None: + """Update state attributes.""" zone_number = self.data["relay"] - LOGGER.debug("Updating Hydrawise switch: %s", self.name) timestr = self.coordinator.api.relays_by_zone_number[zone_number]["timestr"] if self.entity_description.key == "manual_watering": self._attr_is_on = timestr == "Now" elif self.entity_description.key == "auto_watering": self._attr_is_on = timestr not in {"", "Now"} - super()._handle_coordinator_update() diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 5fd23ba47e0..9496752dce7 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -4,8 +4,9 @@ from __future__ import annotations import logging from attr import dataclass -from bleak import BleakError +from bleak.exc import BleakError from idasen_ha import Desk +from idasen_ha.errors import AuthFailedError from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -15,7 +16,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -28,41 +29,81 @@ PLATFORMS: list[Platform] = [Platform.COVER] _LOGGER = logging.getLogger(__name__) +class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): + """Class to manage updates for the Idasen Desk.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + name: str, + address: str, + ) -> None: + """Init IdasenDeskCoordinator.""" + + super().__init__(hass, logger, name=name) + self._address = address + self._expected_connected = False + + self.desk = Desk(self.async_set_updated_data) + + async def async_connect(self) -> bool: + """Connect to desk.""" + _LOGGER.debug("Trying to connect %s", self._address) + ble_device = bluetooth.async_ble_device_from_address( + self.hass, self._address, connectable=True + ) + if ble_device is None: + return False + self._expected_connected = True + await self.desk.connect(ble_device) + return True + + async def async_disconnect(self) -> None: + """Disconnect from desk.""" + _LOGGER.debug("Disconnecting from %s", self._address) + self._expected_connected = False + await self.desk.disconnect() + + @callback + def async_set_updated_data(self, data: int | None) -> None: + """Handle data update.""" + if self._expected_connected: + if not self.desk.is_connected: + _LOGGER.debug("Desk disconnected. Reconnecting") + self.hass.async_create_task(self.async_connect()) + elif self.desk.is_connected: + _LOGGER.warning("Desk is connected but should not be. Disconnecting") + self.hass.async_create_task(self.desk.disconnect()) + return super().async_set_updated_data(data) + + @dataclass class DeskData: """Data for the Idasen Desk integration.""" - desk: Desk address: str device_info: DeviceInfo - coordinator: DataUpdateCoordinator + coordinator: IdasenDeskCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IKEA Idasen from a config entry.""" address: str = entry.data[CONF_ADDRESS].upper() - coordinator: DataUpdateCoordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=entry.title, - ) - - desk = Desk(coordinator.async_set_updated_data) + coordinator = IdasenDeskCoordinator(hass, _LOGGER, entry.title, address) device_info = DeviceInfo( name=entry.title, connections={(dr.CONNECTION_BLUETOOTH, address)}, ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DeskData( - desk, address, device_info, coordinator + address, device_info, coordinator ) - ble_device = bluetooth.async_ble_device_from_address( - hass, address, connectable=True - ) try: - await desk.connect(ble_device) - except (TimeoutError, BleakError) as ex: + if not await coordinator.async_connect(): + raise ConfigEntryNotReady(f"Unable to connect to desk {address}") + except (AuthFailedError, TimeoutError, BleakError, Exception) as ex: raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -70,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_stop(event: Event) -> None: """Close the connection.""" - await desk.disconnect() + await coordinator.async_disconnect() entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) @@ -89,6 +130,7 @@ 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): data: DeskData = hass.data[DOMAIN].pop(entry.entry_id) - await data.desk.disconnect() + await data.coordinator.async_disconnect() + bluetooth.async_rediscover_address(hass, data.address) return unload_ok diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index 92f5a836751..caa8d866fc3 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -6,7 +6,8 @@ from typing import Any from bleak.exc import BleakError from bluetooth_data_tools import human_readable_name -from idasen_ha import AuthFailedError, Desk +from idasen_ha import Desk +from idasen_ha.errors import AuthFailedError import voluptuous as vol from homeassistant import config_entries @@ -61,9 +62,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - desk = Desk(None) + desk = Desk(None, monitor_height=False) try: - await desk.connect(discovery_info.device, monitor_height=False) + await desk.connect(discovery_info.device, auto_reconnect=False) except AuthFailedError as err: _LOGGER.exception("AuthFailedError", exc_info=err) errors["base"] = "auth_failed" diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index c1d1bb48fd8..3148616d182 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -1,11 +1,8 @@ """Idasen Desk integration cover platform.""" from __future__ import annotations -import logging from typing import Any -from idasen_ha import Desk - from homeassistant.components.cover import ( ATTR_POSITION, CoverDeviceClass, @@ -17,16 +14,11 @@ from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DeskData +from . import DeskData, IdasenDeskCoordinator from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, @@ -36,11 +28,11 @@ async def async_setup_entry( """Set up the cover platform for Idasen Desk.""" data: DeskData = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [IdasenDeskCover(data.desk, data.address, data.device_info, data.coordinator)] + [IdasenDeskCover(data.address, data.device_info, data.coordinator)] ) -class IdasenDeskCover(CoordinatorEntity, CoverEntity): +class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity): """Representation of Idasen Desk device.""" _attr_device_class = CoverDeviceClass.DAMPER @@ -54,14 +46,13 @@ class IdasenDeskCover(CoordinatorEntity, CoverEntity): def __init__( self, - desk: Desk, address: str, device_info: DeviceInfo, - coordinator: DataUpdateCoordinator, + coordinator: IdasenDeskCoordinator, ) -> None: """Initialize an Idasen Desk cover.""" super().__init__(coordinator) - self._desk = desk + self._desk = coordinator.desk self._attr_name = device_info[ATTR_NAME] self._attr_unique_id = address self._attr_device_info = device_info diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index cdb06cf907d..ed941f4f87d 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -11,5 +11,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", - "requirements": ["idasen-ha==1.4.1"] + "requirements": ["idasen-ha==2.3"] } diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index 8bd267891a6..a0b87bd4932 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import re import voluptuous as vol @@ -160,7 +159,7 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): """Return one or more digits/characters.""" if self._code is None: return None - if isinstance(self._code, str) and re.search("^\\d+$", self._code): + if isinstance(self._code, str) and self._code.isdigit(): return CodeFormat.NUMBER return CodeFormat.TEXT diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index b6c74f0c53c..5ffeec1ca41 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==10.0.1"] + "requirements": ["Pillow==10.1.0"] } diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 4c4a2e2a35c..70594d5fd7c 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,13 +10,7 @@ from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv @@ -132,28 +126,6 @@ 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(self.hass, 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: diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py deleted file mode 100644 index f2041b947df..00000000000 --- a/homeassistant/components/imap_email_content/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""The imap_email_content component.""" - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN - -PLATFORMS = [Platform.SENSOR] - -CONFIG_SCHEMA = cv.deprecated(DOMAIN, raise_if_present=False) - - -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 deleted file mode 100644 index 5f1c653030e..00000000000 --- a/homeassistant/components/imap_email_content/const.py +++ /dev/null @@ -1,13 +0,0 @@ -"""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 deleted file mode 100644 index b7d0589b83f..00000000000 --- a/homeassistant/components/imap_email_content/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 8fe05f80c08..00000000000 --- a/homeassistant/components/imap_email_content/repairs.py +++ /dev/null @@ -1,173 +0,0 @@ -"""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.11.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.11.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 deleted file mode 100644 index 1df207e2968..00000000000 --- a/homeassistant/components/imap_email_content/sensor.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Email sensor support.""" -from __future__ import annotations - -from collections import deque -import datetime -import email -import imaplib -import logging - -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_DATE, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - CONTENT_TYPE_TEXT_PLAIN, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.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__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_SERVER): cv.string, - vol.Required(CONF_SENDERS): [cv.string], - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Email sensor platform.""" - reader = EmailReader( - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_SERVER], - config[CONF_PORT], - config[CONF_FOLDER], - config[CONF_VERIFY_SSL], - ) - - if (value_template := config.get(CONF_VALUE_TEMPLATE)) is not None: - value_template.hass = hass - sensor = EmailContentSensor( - hass, - reader, - config.get(CONF_NAME) or config[CONF_USERNAME], - config[CONF_SENDERS], - value_template, - ) - - hass.add_job(async_process_issue, hass, config) - - if sensor.connected: - add_entities([sensor], True) - - -class EmailReader: - """A class to read emails from an IMAP server.""" - - def __init__(self, user, password, server, port, folder, verify_ssl): - """Initialize the Email Reader.""" - self._user = user - self._password = password - self._server = server - self._port = port - self._folder = folder - self._verify_ssl = verify_ssl - self._last_id = None - self._last_message = None - self._unread_ids = deque([]) - self.connection = None - - @property - def last_id(self) -> int | None: - """Return last email uid that was processed.""" - return self._last_id - - @property - def last_unread_id(self) -> int | None: - """Return last email uid received.""" - # We assume the last id in the list is the last unread id - # We cannot know if that is the newest one, because it could arrive later - # https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids - if self._unread_ids: - return int(self._unread_ids[-1]) - return self._last_id - - def connect(self): - """Login and setup the connection.""" - ssl_context = client_context() if self._verify_ssl else None - try: - self.connection = imaplib.IMAP4_SSL( - self._server, self._port, ssl_context=ssl_context - ) - self.connection.login(self._user, self._password) - return True - except imaplib.IMAP4.error: - _LOGGER.error("Failed to login to %s", self._server) - return False - - def _fetch_message(self, message_uid): - """Get an email message from a message id.""" - _, message_data = self.connection.uid("fetch", message_uid, "(RFC822)") - - if message_data is None: - return None - if message_data[0] is None: - return None - raw_email = message_data[0][1] - email_message = email.message_from_bytes(raw_email) - return email_message - - def read_next(self): - """Read the next email from the email server.""" - try: - self.connection.select(self._folder, readonly=True) - - if self._last_id is None: - # search for today and yesterday - time_from = datetime.datetime.now() - datetime.timedelta(days=1) - search = f"SINCE {time_from:%d-%b-%Y}" - else: - search = f"UID {self._last_id}:*" - - _, data = self.connection.uid("search", None, search) - self._unread_ids = deque(data[0].split()) - while self._unread_ids: - message_uid = self._unread_ids.popleft() - if self._last_id is None or int(message_uid) > self._last_id: - self._last_id = int(message_uid) - self._last_message = self._fetch_message(message_uid) - return self._last_message - - except imaplib.IMAP4.error: - _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) - try: - self.connect() - _LOGGER.info( - "Reconnect to %s succeeded, trying last message", self._server - ) - if self._last_id is not None: - return self._fetch_message(str(self._last_id)) - except imaplib.IMAP4.error: - _LOGGER.error("Failed to reconnect") - - return None - - -class EmailContentSensor(SensorEntity): - """Representation of an EMail sensor.""" - - def __init__(self, hass, email_reader, name, allowed_senders, value_template): - """Initialize the sensor.""" - self.hass = hass - self._email_reader = email_reader - self._name = name - self._allowed_senders = [sender.upper() for sender in allowed_senders] - self._value_template = value_template - self._last_id = None - self._message = None - self._state_attributes = None - self.connected = self._email_reader.connect() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the current email state.""" - return self._message - - @property - def extra_state_attributes(self): - """Return other state attributes for the message.""" - return self._state_attributes - - def render_template(self, email_message): - """Render the message template.""" - variables = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } - return self._value_template.render(variables, parse_result=False) - - def sender_allowed(self, email_message): - """Check if the sender is in the allowed senders list.""" - return EmailContentSensor.get_msg_sender(email_message).upper() in ( - sender for sender in self._allowed_senders - ) - - @staticmethod - def get_msg_sender(email_message): - """Get the parsed message sender from the email.""" - return str(email.utils.parseaddr(email_message["From"])[1]) - - @staticmethod - def get_msg_subject(email_message): - """Decode the message subject.""" - decoded_header = email.header.decode_header(email_message["Subject"]) - header = email.header.make_header(decoded_header) - return str(header) - - @staticmethod - def get_msg_text(email_message): - """Get the message text from the email. - - Will look for text/plain or use text/html if not found. - """ - message_text = None - message_html = None - message_untyped_text = None - - for part in email_message.walk(): - if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: - if message_text is None: - message_text = part.get_payload() - elif part.get_content_type() == "text/html": - if message_html is None: - message_html = part.get_payload() - elif ( - part.get_content_type().startswith("text") - and message_untyped_text is None - ): - message_untyped_text = part.get_payload() - - if message_text is not None: - return message_text - - if message_html is not None: - return message_html - - if message_untyped_text is not None: - return message_untyped_text - - return email_message.get_payload() - - def update(self) -> None: - """Read emails and publish state change.""" - email_message = self._email_reader.read_next() - while ( - self._last_id is None or self._last_id != self._email_reader.last_unread_id - ): - if email_message is None: - self._message = None - self._state_attributes = {} - return - - self._last_id = self._email_reader.last_id - - if self.sender_allowed(email_message): - message = EmailContentSensor.get_msg_subject(email_message) - - if self._value_template is not None: - message = self.render_template(email_message) - - self._message = message - self._state_attributes = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } - - if self._last_id == self._email_reader.last_unread_id: - break - email_message = self._email_reader.read_next() diff --git a/homeassistant/components/imap_email_content/strings.json b/homeassistant/components/imap_email_content/strings.json deleted file mode 100644 index b7b987b1212..00000000000 --- a/homeassistant/components/imap_email_content/strings.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 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/improv_ble/__init__.py b/homeassistant/components/improv_ble/__init__.py new file mode 100644 index 00000000000..985684cb5b8 --- /dev/null +++ b/homeassistant/components/improv_ble/__init__.py @@ -0,0 +1 @@ +"""The Improv BLE integration.""" diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py new file mode 100644 index 00000000000..bfc86ac0162 --- /dev/null +++ b/homeassistant/components/improv_ble/config_flow.py @@ -0,0 +1,415 @@ +"""Config flow for Improv via BLE integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any, TypeVar + +from bleak import BleakError +from improv_ble_client import ( + SERVICE_DATA_UUID, + Error, + ImprovBLEClient, + ImprovServiceData, + State, + device_filter, + errors as improv_ble_errors, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import bluetooth +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_T = TypeVar("_T") + +STEP_PROVISION_SCHEMA = vol.Schema( + { + vol.Required("ssid"): str, + vol.Optional("password"): str, + } +) + + +@dataclass +class Credentials: + """Container for WiFi credentials.""" + + password: str + ssid: str + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Improv via BLE.""" + + VERSION = 1 + + _authorize_task: asyncio.Task | None = None + _can_identify: bool | None = None + _credentials: Credentials | None = None + _provision_result: FlowResult | None = None + _provision_task: asyncio.Task | None = None + _reauth_entry: config_entries.ConfigEntry | None = None + _remove_bluetooth_callback: Callable[[], None] | None = None + _unsub: Callable[[], None] | None = None + + def __init__(self) -> None: + """Initialize the config flow.""" + self._device: ImprovBLEClient | None = None + # Populated by user step + self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {} + # Populated by bluetooth, reauth_confirm and user steps + self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + # Guard against the user selecting a device which has been configured by + # another flow. + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices[address] + return await self.async_step_start_improv() + + current_addresses = self._async_current_ids() + for discovery in bluetooth.async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or not device_filter(discovery.advertisement) + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + def _abort_if_provisioned(self) -> None: + """Check improv state and abort flow if needed.""" + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + service_data = self._discovery_info.service_data + improv_service_data = ImprovServiceData.from_bytes( + service_data[SERVICE_DATA_UUID] + ) + if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): + _LOGGER.debug( + "Aborting improv flow, device is already provisioned: %s", + improv_service_data.state, + ) + raise AbortFlow("already_provisioned") + + @callback + def _async_update_ble( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update from a ble callback.""" + _LOGGER.debug( + "Got updated BLE data: %s", + service_info.service_data[SERVICE_DATA_UUID].hex(), + ) + + self._discovery_info = service_info + try: + self._abort_if_provisioned() + except AbortFlow: + self.hass.config_entries.flow.async_abort(self.flow_id) + + def _unregister_bluetooth_callback(self) -> None: + """Unregister bluetooth callbacks.""" + if not self._remove_bluetooth_callback: + return + self._remove_bluetooth_callback() + self._remove_bluetooth_callback = None + + async def async_step_bluetooth( + self, discovery_info: bluetooth.BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the Bluetooth discovery step.""" + self._discovery_info = discovery_info + + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._abort_if_provisioned() + + self._remove_bluetooth_callback = bluetooth.async_register_callback( + self.hass, + self._async_update_ble, + bluetooth.BluetoothCallbackMatcher( + {bluetooth.match.ADDRESS: discovery_info.address} + ), + bluetooth.BluetoothScanningMode.PASSIVE, + ) + + name = self._discovery_info.name or self._discovery_info.address + self.context["title_placeholders"] = {"name": name} + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle bluetooth confirm step.""" + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + if user_input is None: + name = self._discovery_info.name or self._discovery_info.address + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": name}, + ) + + self._unregister_bluetooth_callback() + return await self.async_step_start_improv() + + async def async_step_start_improv( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Start improv flow. + + If the device supports identification, show a menu, if it does not, + ask for WiFi credentials. + """ + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + if not self._device: + self._device = ImprovBLEClient(self._discovery_info.device) + device = self._device + + if self._can_identify is None: + try: + self._can_identify = await self._try_call(device.can_identify()) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + if self._can_identify: + return await self.async_step_main_menu() + return await self.async_step_provision() + + async def async_step_main_menu(self, _: None = None) -> FlowResult: + """Show the main menu.""" + return self.async_show_menu( + step_id="main_menu", + menu_options=[ + "identify", + "provision", + ], + ) + + async def async_step_identify( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle identify step.""" + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + + if user_input is None: + try: + await self._try_call(self._device.identify()) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + return self.async_show_form(step_id="identify") + return await self.async_step_start_improv() + + async def async_step_provision( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle provision step.""" + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + + if user_input is None and self._credentials is None: + return self.async_show_form( + step_id="provision", data_schema=STEP_PROVISION_SCHEMA + ) + if user_input is not None: + self._credentials = Credentials( + user_input.get("password", ""), user_input["ssid"] + ) + + try: + need_authorization = await self._try_call(self._device.need_authorization()) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + _LOGGER.debug("Need authorization: %s", need_authorization) + if need_authorization: + return await self.async_step_authorize() + return await self.async_step_do_provision() + + async def async_step_do_provision( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Execute provisioning.""" + + async def _do_provision() -> None: + # mypy is not aware that we can't get here without having these set already + assert self._credentials is not None + assert self._device is not None + + errors = {} + try: + redirect_url = await self._try_call( + self._device.provision( + self._credentials.ssid, self._credentials.password, None + ) + ) + except AbortFlow as err: + self._provision_result = self.async_abort(reason=err.reason) + return + except improv_ble_errors.ProvisioningFailed as err: + if err.error == Error.NOT_AUTHORIZED: + _LOGGER.debug("Need authorization when calling provision") + self._provision_result = await self.async_step_authorize() + return + if err.error == Error.UNABLE_TO_CONNECT: + self._credentials = None + errors["base"] = "unable_to_connect" + else: + self._provision_result = self.async_abort(reason="unknown") + return + else: + _LOGGER.debug("Provision successful, redirect URL: %s", redirect_url) + # Abort all flows in progress with same unique ID + for flow in self._async_in_progress(include_uninitialized=True): + flow_unique_id = flow["context"].get("unique_id") + if ( + flow["flow_id"] != self.flow_id + and self.unique_id == flow_unique_id + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + if redirect_url: + self._provision_result = self.async_abort( + reason="provision_successful_url", + description_placeholders={"url": redirect_url}, + ) + return + self._provision_result = self.async_abort(reason="provision_successful") + return + self._provision_result = self.async_show_form( + step_id="provision", data_schema=STEP_PROVISION_SCHEMA, errors=errors + ) + return + + if not self._provision_task: + self._provision_task = self.hass.async_create_task( + self._resume_flow_when_done(_do_provision()) + ) + return self.async_show_progress( + step_id="do_provision", progress_action="provisioning" + ) + + await self._provision_task + self._provision_task = None + return self.async_show_progress_done(next_step_id="provision_done") + + async def async_step_provision_done( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show the result of the provision step.""" + # mypy is not aware that we can't get here without having these set already + assert self._provision_result is not None + + result = self._provision_result + self._provision_result = None + return result + + async def _resume_flow_when_done(self, awaitable: Awaitable) -> None: + try: + await awaitable + finally: + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle authorize step.""" + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + + _LOGGER.debug("Wait for authorization") + if not self._authorize_task: + authorized_event = asyncio.Event() + + def on_state_update(state: State) -> None: + _LOGGER.debug("State update: %s", state.name) + if state != State.AUTHORIZATION_REQUIRED: + authorized_event.set() + + try: + self._unsub = await self._try_call( + self._device.subscribe_state_updates(on_state_update) + ) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + + self._authorize_task = self.hass.async_create_task( + self._resume_flow_when_done(authorized_event.wait()) + ) + return self.async_show_progress( + step_id="authorize", progress_action="authorize" + ) + + await self._authorize_task + self._authorize_task = None + if self._unsub: + self._unsub() + self._unsub = None + return self.async_show_progress_done(next_step_id="provision") + + @staticmethod + async def _try_call(func: Coroutine[Any, Any, _T]) -> _T: + """Call the library and abort flow on common errors.""" + try: + return await func + except BleakError as err: + _LOGGER.warning("BleakError", exc_info=err) + raise AbortFlow("cannot_connect") from err + except improv_ble_errors.CharacteristicMissingError as err: + _LOGGER.warning("CharacteristicMissing", exc_info=err) + raise AbortFlow("characteristic_missing") from err + except improv_ble_errors.CommandFailed: + raise + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + raise AbortFlow("unknown") from err + + @callback + def async_remove(self) -> None: + """Notification that the flow has been removed.""" + self._unregister_bluetooth_callback() diff --git a/homeassistant/components/improv_ble/const.py b/homeassistant/components/improv_ble/const.py new file mode 100644 index 00000000000..0641773a055 --- /dev/null +++ b/homeassistant/components/improv_ble/const.py @@ -0,0 +1,3 @@ +"""Constants for the Improv BLE integration.""" + +DOMAIN = "improv_ble" diff --git a/homeassistant/components/improv_ble/manifest.json b/homeassistant/components/improv_ble/manifest.json new file mode 100644 index 00000000000..30af6e111a0 --- /dev/null +++ b/homeassistant/components/improv_ble/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "improv_ble", + "name": "Improv via BLE", + "bluetooth": [ + { + "service_uuid": "00467768-6228-2272-4663-277478268000", + "service_data_uuid": "00004677-0000-1000-8000-00805f9b34fb" + } + ], + "codeowners": ["@emontnemery"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/improv_ble", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["py-improv-ble-client==1.0.3"] +} diff --git a/homeassistant/components/improv_ble/strings.json b/homeassistant/components/improv_ble/strings.json new file mode 100644 index 00000000000..b5713910134 --- /dev/null +++ b/homeassistant/components/improv_ble/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "identify": { + "description": "The device is now identifying itself, for example by blinking or beeping." + }, + "main_menu": { + "description": "Choose next step.", + "menu_options": { + "identify": "Identify device", + "provision": "Connect device to a Wi-Fi network" + } + }, + "provision": { + "description": "Enter Wi-Fi credentials to connect the device to your network.", + "data": { + "password": "Password", + "ssid": "SSID" + } + } + }, + "progress": { + "authorize": "The device requires authorization, please press its authorization button or consult the device's manual for how to proceed.", + "provisioning": "The device is connecting to the Wi-Fi network." + }, + "error": { + "unable_to_connect": "The device could not connect to the Wi-Fi network. Check that the SSID and password are correct and try again." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "characteristic_missing": "The device is either already connected to Wi-Fi, or no longer able to connect to Wi-Fi. If you want to connect it to another network, try factory resetting it first.", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "provision_successful": "The device has successfully connected to the Wi-Fi network.", + "provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease visit {url} to finish setup.", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/input_text/strings.json b/homeassistant/components/input_text/strings.json index 49eab33848c..13a86a329a7 100644 --- a/homeassistant/components/input_text/strings.json +++ b/homeassistant/components/input_text/strings.json @@ -18,10 +18,10 @@ "name": "[%key:component::text::entity_component::_::state_attributes::min::name%]" }, "mode": { - "name": "[%key:component::text::entity_component::_::state_attributes::mode::name%]", + "name": "[%key:common::config_flow::data::mode%]", "state": { "text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]", - "password": "[%key:component::text::entity_component::_::state_attributes::mode::state::password%]" + "password": "[%key:common::config_flow::data::password%]" } }, "pattern": { diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 65840383926..0b1eda7201e 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -6,6 +6,7 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_METHOD, CONF_NAME, UnitOfTime from homeassistant.helpers import selector @@ -26,7 +27,6 @@ from .const import ( ) UNIT_PREFIXES = [ - selector.SelectOptionDict(value="none", label="none"), selector.SelectOptionDict(value="k", label="k (kilo)"), selector.SelectOptionDict(value="M", label="M (mega)"), selector.SelectOptionDict(value="G", label="G (giga)"), @@ -58,7 +58,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( - selector.EntitySelectorConfig(domain=SENSOR_DOMAIN) + selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN]) ), vol.Required(CONF_METHOD, default=METHOD_TRAPEZOIDAL): selector.SelectSelector( selector.SelectSelectorConfig( @@ -73,7 +73,7 @@ CONFIG_SCHEMA = vol.Schema( unit_of_measurement="decimals", ), ), - vol.Required(CONF_UNIT_PREFIX, default="none"): selector.SelectSelector( + vol.Optional(CONF_UNIT_PREFIX): selector.SelectSelector( selector.SelectSelectorConfig(options=UNIT_PREFIXES), ), vol.Required(CONF_UNIT_TIME, default=UnitOfTime.HOURS): selector.SelectSelector( diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 66a99b63681..d7d5c84f17a 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -77,7 +77,7 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), - vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), + vol.Optional(CONF_UNIT_PREFIX): vol.In(UNIT_PREFIXES), vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME), vol.Remove(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_METHOD, default=METHOD_TRAPEZOIDAL): vol.In( @@ -169,8 +169,8 @@ async def async_setup_entry( else: device_info = None - unit_prefix = config_entry.options[CONF_UNIT_PREFIX] - if unit_prefix == "none": + if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": + # Before we had support for optional selectors, "none" was used for selecting nothing unit_prefix = None integral = IntegrationSensor( @@ -200,7 +200,7 @@ async def async_setup_platform( round_digits=config[CONF_ROUND_DIGITS], source_entity=config[CONF_SOURCE_SENSOR], unique_id=config.get(CONF_UNIQUE_ID), - unit_prefix=config[CONF_UNIT_PREFIX], + unit_prefix=config.get(CONF_UNIT_PREFIX), unit_time=config[CONF_UNIT_TIME], ) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index b2c77fed3af..5d3a2259bed 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -56,6 +56,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, GetStateIntentHandler(), ) + intent.async_register( + hass, + NevermindIntentHandler(), + ) return True @@ -206,6 +210,16 @@ class GetStateIntentHandler(intent.IntentHandler): return response +class NevermindIntentHandler(intent.IntentHandler): + """Takes no action.""" + + intent_type = intent.INTENT_NEVERMIND + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Doe not do anything, and produces an empty response.""" + return intent_obj.create_response() + + async def _async_process_intent(hass: HomeAssistant, domain: str, platform): """Process the intents of an integration.""" await platform.async_setup_intents(hass) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index f0cf36b5607..d5bec0573b8 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import TypedDict +from typing import Any, TypedDict import voluptuous as vol @@ -64,7 +64,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None: - """Handle start Intent Script service call.""" + """Handle reload Intent Script service call.""" new_config = await async_integration_yaml_config(hass, DOMAIN) existing_intents = hass.data[DOMAIN] @@ -144,7 +144,7 @@ class ScriptIntentHandler(intent.IntentHandler): card: _IntentCardData | None = self.config.get(CONF_CARD) action: script.Script | None = self.config.get(CONF_ACTION) is_async_action: bool = self.config[CONF_ASYNC_ACTION] - slots: dict[str, str] = { + slots: dict[str, Any] = { key: value["value"] for key, value in intent_obj.slots.items() } @@ -164,7 +164,11 @@ class ScriptIntentHandler(intent.IntentHandler): action.async_run(slots, intent_obj.context) ) else: - await action.async_run(slots, intent_obj.context) + action_res = await action.async_run(slots, intent_obj.context) + + # if the action returns a response, make it available to the speech/reprompt templates below + if action_res and action_res.service_response is not None: + slots["action_response"] = action_res.service_response response = intent_obj.create_response() diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index d8810b0ad45..86ee94f7269 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er from .const import DOMAIN from .coordinator import IslamicPrayerDataUpdateCoordinator @@ -16,6 +16,19 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if not entity_entry.unique_id.startswith(f"{config_entry.entry_id}-"): + new_unique_id = f"{config_entry.entry_id}-{entity_entry.unique_id}" + return {"new_unique_id": new_unique_id} + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + coordinator = IslamicPrayerDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 1a160024a65..9e39f5d04e4 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -186,7 +186,7 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): # Check if this is a known index pair UOM if isinstance(uom, dict): - return uom.get(value, value) + return uom.get(value, value) # type: ignore[no-any-return] if uom in (UOM_INDEX, UOM_ON_OFF): return cast(str, self.target.formatted) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index f25c3410edb..2e9e6bb71f7 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -3,11 +3,11 @@ from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, LOGGER, PLATFORMS +from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, PLATFORMS from .coordinator import JellyfinDataUpdateCoordinator, SessionsDataUpdateCoordinator from .models import JellyfinData @@ -30,9 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: user_id, connect_result = await validate_input(hass, dict(entry.data), client) except CannotConnect as ex: raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex - except InvalidAuth: - LOGGER.error("Failed to login to Jellyfin server") - return False + except InvalidAuth as ex: + raise ConfigEntryAuthFailed(ex) from ex server_info: dict[str, Any] = connect_result["Servers"][0] diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 84b78d51926..84360ed053e 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Jellyfin integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -24,6 +25,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_PASSWORD, default=""): str, + } +) + def _generate_client_device_id() -> str: """Generate a random UUID4 string to identify ourselves.""" @@ -38,6 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Jellyfin config flow.""" self.client_device_id: str | None = None + self.entry: config_entries.ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -83,3 +91,41 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + assert self.entry is not None + new_input = self.entry.data | user_input + + if self.client_device_id is None: + self.client_device_id = _generate_client_device_id() + + client = create_client(device_id=self.client_device_id) + try: + await validate_input(self.hass, new_input, client) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception as ex: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(ex) + else: + self.hass.config_entries.async_update_entry(self.entry, data=new_input) + + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", data_schema=REAUTH_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index 8d74d416a94..3e8965da785 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Jellyfin integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]", @@ -16,7 +23,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/kegtron/strings.json b/homeassistant/components/kegtron/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/kegtron/strings.json +++ b/homeassistant/components/kegtron/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 8e5783dc2d1..6e3da8ad523 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -237,10 +237,7 @@ class KNXCommonFlow(ABC, FlowHandler): tunnel_endpoint_ia=None, ) if connection_type == CONF_KNX_TUNNELING_TCP_SECURE: - return self.async_show_menu( - step_id="secure_key_source", - menu_options=["secure_knxkeys", "secure_tunnel_manual"], - ) + return await self.async_step_secure_key_source_menu_tunnel() self.new_title = f"Tunneling @ {self._selected_tunnel}" return self.finish_flow() @@ -317,10 +314,7 @@ class KNXCommonFlow(ABC, FlowHandler): ) if selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE: - return self.async_show_menu( - step_id="secure_key_source", - menu_options=["secure_knxkeys", "secure_tunnel_manual"], - ) + return await self.async_step_secure_key_source_menu_tunnel() self.new_title = ( "Tunneling " f"{'UDP' if selected_tunnelling_type == CONF_KNX_TUNNELING else 'TCP'} " @@ -680,10 +674,7 @@ class KNXCommonFlow(ABC, FlowHandler): ) if connection_type == CONF_KNX_ROUTING_SECURE: self.new_title = f"Secure Routing as {_individual_address}" - return self.async_show_menu( - step_id="secure_key_source", - menu_options=["secure_knxkeys", "secure_routing_manual"], - ) + return await self.async_step_secure_key_source_menu_routing() self.new_title = f"Routing as {_individual_address}" return self.finish_flow() @@ -712,6 +703,24 @@ class KNXCommonFlow(ABC, FlowHandler): step_id="routing", data_schema=vol.Schema(fields), errors=errors ) + async def async_step_secure_key_source_menu_tunnel( + self, user_input: dict | None = None + ) -> FlowResult: + """Show the key source menu.""" + return self.async_show_menu( + step_id="secure_key_source_menu_tunnel", + menu_options=["secure_knxkeys", "secure_tunnel_manual"], + ) + + async def async_step_secure_key_source_menu_routing( + self, user_input: dict | None = None + ) -> FlowResult: + """Show the key source menu.""" + return self.async_show_menu( + step_id="secure_key_source_menu_routing", + menu_options=["secure_knxkeys", "secure_routing_manual"], + ) + class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): """Handle a KNX config flow.""" @@ -770,7 +779,7 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): ) -> FlowResult: """Manage KNX options.""" return self.async_show_menu( - step_id="options_init", + step_id="init", menu_options=[ "connection_type", "communication_settings", diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index b5c98c7203a..a233ca38705 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.11.2", - "xknxproject==3.3.0", + "xknxproject==3.4.0", "knx-frontend==2023.6.23.191712" ] } diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 1ff008653d4..5f5a2263eac 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -32,12 +32,19 @@ "local_ip": "Local IP or interface name used for the connection from Home Assistant. Leave blank to use auto-discovery." } }, - "secure_key_source": { + "secure_key_source_menu_tunnel": { "title": "KNX IP-Secure", "description": "Select how you want to configure KNX/IP Secure.", "menu_options": { "secure_knxkeys": "Use a `.knxkeys` file containing IP secure keys", - "secure_tunnel_manual": "Configure IP secure credentials manually", + "secure_tunnel_manual": "Configure IP secure credentials manually" + } + }, + "secure_key_source_menu_routing": { + "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", + "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", + "menu_options": { + "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", "secure_routing_manual": "Configure IP secure backbone key manually" } }, @@ -121,7 +128,7 @@ }, "options": { "step": { - "options_init": { + "init": { "title": "KNX Settings", "menu_options": { "connection_type": "Configure KNX interface", @@ -130,7 +137,7 @@ } }, "communication_settings": { - "title": "[%key:component::knx::options::step::options_init::menu_options::communication_settings%]", + "title": "[%key:component::knx::options::step::init::menu_options::communication_settings%]", "data": { "state_updater": "State updater", "rate_limit": "Rate limit", @@ -173,13 +180,20 @@ "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } }, - "secure_key_source": { - "title": "[%key:component::knx::config::step::secure_key_source::title%]", - "description": "[%key:component::knx::config::step::secure_key_source::description%]", + "secure_key_source_menu_tunnel": { + "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", + "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source::menu_options::secure_knxkeys%]", - "secure_tunnel_manual": "[%key:component::knx::config::step::secure_key_source::menu_options::secure_tunnel_manual%]", - "secure_routing_manual": "[%key:component::knx::config::step::secure_key_source::menu_options::secure_routing_manual%]" + "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", + "secure_tunnel_manual": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_tunnel_manual%]" + } + }, + "secure_key_source_menu_routing": { + "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", + "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", + "menu_options": { + "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", + "secure_routing_manual": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_routing_manual%]" } }, "secure_knxkeys": { @@ -216,7 +230,7 @@ }, "secure_routing_manual": { "title": "[%key:component::knx::config::step::secure_routing_manual::title%]", - "description": "[%key:component::knx::config::step::secure_routing_manual::description%]", + "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", "data": { "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data::backbone_key%]", "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data::sync_latency_tolerance%]" @@ -234,11 +248,11 @@ "routing_secure": "[%key:component::knx::config::step::routing::data::routing_secure%]", "multicast_group": "[%key:component::knx::config::step::routing::data::multicast_group%]", "multicast_port": "[%key:component::knx::config::step::routing::data::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::routing::data::local_ip%]" + "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" }, "data_description": { "individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]", - "local_ip": "[%key:component::knx::config::step::routing::data_description::local_ip%]" + "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } } }, diff --git a/homeassistant/components/kodi/manifest.json b/homeassistant/components/kodi/manifest.json index 16574844a01..708a15e0fc2 100644 --- a/homeassistant/components/kodi/manifest.json +++ b/homeassistant/components/kodi/manifest.json @@ -2,7 +2,7 @@ "domain": "kodi", "name": "Kodi", "after_dependencies": ["media_source"], - "codeowners": ["@OnFreund", "@cgtobi"], + "codeowners": ["@OnFreund"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kodi", "iot_class": "local_push", diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index 0d62e6cfa10..f3459e891b7 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -64,7 +64,7 @@ async def async_get_service( _LOGGER.warning( "Kodi host name should no longer contain http:// See updated " "definitions here: " - "https://www.home-assistant.io/integrations/media_player.kodi/" + "https://www.home-assistant.io/integrations/kodi/" ) http_protocol = "https" if encryption else "http" diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 21d2bdc84bd..e7bfc059674 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -115,7 +115,7 @@ "description": "Displays a message with an optional icon on a LaMetric device.", "fields": { "device_id": { - "name": "[%key:component::lametric::services::chart::fields::device_id::name%]", + "name": "[%key:common::config_flow::data::device%]", "description": "The LaMetric device to display the message on." }, "message": { diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 85183a2d616..5dab7da56ed 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -49,7 +50,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="next_launch", icon="mdi:rocket-launch", - name="Next launch", + translation_key="next_launch", value_fn=lambda nl: nl.name, attributes_fn=lambda nl: { "provider": nl.launch_service_provider.name, @@ -61,7 +62,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="launch_time", icon="mdi:clock-outline", - name="Launch time", + translation_key="launch_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda nl: parse_datetime(nl.net), attributes_fn=lambda nl: { @@ -73,7 +74,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="launch_probability", icon="mdi:dice-multiple", - name="Launch probability", + translation_key="next_launch", native_unit_of_measurement=PERCENTAGE, value_fn=lambda nl: None if nl.probability == -1 else nl.probability, attributes_fn=lambda nl: None, @@ -81,14 +82,14 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="launch_status", icon="mdi:rocket-launch", - name="Launch status", + translation_key="next_launch", value_fn=lambda nl: nl.status.name, attributes_fn=lambda nl: {"reason": nl.holdreason} if nl.inhold else None, ), LaunchLibrarySensorEntityDescription( key="launch_mission", icon="mdi:orbit", - name="Launch mission", + translation_key="launch_mission", value_fn=lambda nl: nl.mission.name, attributes_fn=lambda nl: { "mission_type": nl.mission.type, @@ -99,7 +100,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="starship_launch", icon="mdi:rocket", - name="Next Starship launch", + translation_key="starship_launch", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda sl: parse_datetime(sl.net), attributes_fn=lambda sl: { @@ -112,7 +113,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="starship_event", icon="mdi:calendar", - name="Next Starship event", + translation_key="starship_event", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda se: parse_datetime(se.date), attributes_fn=lambda se: { @@ -139,7 +140,7 @@ async def async_setup_entry( coordinator=coordinator, entry_id=entry.entry_id, description=description, - name=name if description.key == "next_launch" else None, + name=name, ) for description in SENSOR_DESCRIPTIONS ) @@ -151,6 +152,7 @@ class LaunchLibrarySensor( """Representation of the next launch sensors.""" _attr_attribution = "Data provided by Launch Library." + _attr_has_entity_name = True _next_event: Launch | Event | None = None entity_description: LaunchLibrarySensorEntityDescription @@ -159,14 +161,17 @@ class LaunchLibrarySensor( coordinator: DataUpdateCoordinator[LaunchLibraryData], entry_id: str, description: LaunchLibrarySensorEntityDescription, - name: str | None = None, + name: str, ) -> None: """Initialize a Launch Library sensor.""" super().__init__(coordinator) - if name: - self._attr_name = name self._attr_unique_id = f"{entry_id}_{description.key}" self.entity_description = description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + name=name, + ) @property def native_value(self) -> datetime | str | int | None: diff --git a/homeassistant/components/launch_library/strings.json b/homeassistant/components/launch_library/strings.json index 5c6295e0f98..f3cca9fc581 100644 --- a/homeassistant/components/launch_library/strings.json +++ b/homeassistant/components/launch_library/strings.json @@ -8,5 +8,30 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "next_launch": { + "name": "Next launch" + }, + "launch_time": { + "name": "Launch time" + }, + "launch_probability": { + "name": "Launch probability" + }, + "launch_status": { + "name": "Launch status" + }, + "launch_mission": { + "name": "Launch mission" + }, + "starship_launch": { + "name": "Next Starship launch" + }, + "starship_event": { + "name": "Next Starship event" + } + } } } diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index cfcb1e13a07..78cccde5890 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -999,58 +999,83 @@ class LightEntity(ToggleEntity): @property def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" - if not self.is_on: - return None - data: dict[str, Any] = {} supported_features = self.supported_features - color_mode = self._light_internal_color_mode + supported_color_modes = self._light_internal_supported_color_modes + color_mode = self._light_internal_color_mode if self.is_on else None - if color_mode not in self._light_internal_supported_color_modes: + if color_mode and color_mode not in supported_color_modes: # Increase severity to warning in 2021.6, reject in 2021.10 _LOGGER.debug( "%s: set to unsupported color_mode: %s, supported_color_modes: %s", self.entity_id, color_mode, - self._light_internal_supported_color_modes, + supported_color_modes, ) data[ATTR_COLOR_MODE] = color_mode - if color_mode in COLOR_MODES_BRIGHTNESS: - data[ATTR_BRIGHTNESS] = self.brightness + if brightness_supported(self.supported_color_modes): + if color_mode in COLOR_MODES_BRIGHTNESS: + data[ATTR_BRIGHTNESS] = self.brightness + else: + data[ATTR_BRIGHTNESS] = None elif supported_features & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states # Add warning in 2021.6, remove in 2021.10 - data[ATTR_BRIGHTNESS] = self.brightness - - if color_mode == ColorMode.COLOR_TEMP: - data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin - if not self.color_temp_kelvin: - data[ATTR_COLOR_TEMP] = None + if self.is_on: + data[ATTR_BRIGHTNESS] = self.brightness else: - data[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - self.color_temp_kelvin - ) + data[ATTR_BRIGHTNESS] = None - if color_mode in COLOR_MODES_COLOR or color_mode == ColorMode.COLOR_TEMP: - data.update(self._light_internal_convert_color(color_mode)) - - if supported_features & SUPPORT_COLOR_TEMP and not self.supported_color_modes: + if color_temp_supported(self.supported_color_modes): + if color_mode == ColorMode.COLOR_TEMP: + data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin + if self.color_temp_kelvin: + data[ + ATTR_COLOR_TEMP + ] = color_util.color_temperature_kelvin_to_mired( + self.color_temp_kelvin + ) + else: + data[ATTR_COLOR_TEMP] = None + else: + data[ATTR_COLOR_TEMP_KELVIN] = None + data[ATTR_COLOR_TEMP] = None + elif supported_features & SUPPORT_COLOR_TEMP: # Backwards compatibility # Add warning in 2021.6, remove in 2021.10 - data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin - if not self.color_temp_kelvin: - data[ATTR_COLOR_TEMP] = None + if self.is_on: + data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin + if self.color_temp_kelvin: + data[ + ATTR_COLOR_TEMP + ] = color_util.color_temperature_kelvin_to_mired( + self.color_temp_kelvin + ) + else: + data[ATTR_COLOR_TEMP] = None else: - data[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - self.color_temp_kelvin - ) + data[ATTR_COLOR_TEMP_KELVIN] = None + data[ATTR_COLOR_TEMP] = None + + if color_supported(supported_color_modes) or color_temp_supported( + supported_color_modes + ): + data[ATTR_HS_COLOR] = None + data[ATTR_RGB_COLOR] = None + data[ATTR_XY_COLOR] = None + if ColorMode.RGBW in supported_color_modes: + data[ATTR_RGBW_COLOR] = None + if ColorMode.RGBWW in supported_color_modes: + data[ATTR_RGBWW_COLOR] = None + if color_mode: + data.update(self._light_internal_convert_color(color_mode)) if supported_features & LightEntityFeature.EFFECT: - data[ATTR_EFFECT] = self.effect + data[ATTR_EFFECT] = self.effect if self.is_on else None - return {key: val for key, val in data.items() if val is not None} + return data @property def _light_internal_supported_color_modes(self) -> set[ColorMode] | set[str]: diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 15141b6d428..f055f02ebda 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -149,31 +149,29 @@ async def _async_reproduce_state( service = SERVICE_TURN_ON for attr in ATTR_GROUP: # All attributes that are not colors - if attr in state.attributes: - service_data[attr] = state.attributes[attr] + if (attr_state := state.attributes.get(attr)) is not None: + service_data[attr] = attr_state if ( state.attributes.get(ATTR_COLOR_MODE, ColorMode.UNKNOWN) != ColorMode.UNKNOWN ): color_mode = state.attributes[ATTR_COLOR_MODE] - if color_mode_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): - if color_mode_attr.state_attr not in state.attributes: + if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): + if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None: _LOGGER.warning( "Color mode %s specified but attribute %s missing for: %s", color_mode, - color_mode_attr.state_attr, + cm_attr.state_attr, state.entity_id, ) return - service_data[color_mode_attr.parameter] = state.attributes[ - color_mode_attr.state_attr - ] + service_data[cm_attr.parameter] = cm_attr_state else: # Fall back to Choosing the first color that is specified for color_attr in COLOR_GROUP: - if color_attr in state.attributes: - service_data[color_attr] = state.attributes[color_attr] + if (color_attr_state := state.attributes.get(color_attr)) is not None: + service_data[color_attr] = color_attr_state break elif state.state == STATE_OFF: diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 1ba204e5eda..433da53a570 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -422,7 +422,7 @@ toggle: advanced: true example: "[255, 100, 100]" selector: - object: + color_rgb: color_name: filter: attribute: diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 7acfad69735..85d75e13dd2 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -25,6 +25,30 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "issues": { + "service_deprecation_turn_off": { + "title": "Litter-Robot vaccum support for {old_service} is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", + "description": "Litter-Robot vaccum support for the {old_service} service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call {new_service} and select submit below to mark this issue as resolved." + } + } + } + }, + "service_deprecation_turn_on": { + "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", + "description": "[%key:component::litterrobot::issues::service_deprecation_turn_off::fix_flow::step::confirm::description%]" + } + } + } + } + }, "entity": { "binary_sensor": { "sleeping": { diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index d1352c1e45f..4b1a8effb98 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -20,7 +20,11 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + issue_registry as ir, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -77,7 +81,7 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): _attr_supported_features = ( VacuumEntityFeature.START | VacuumEntityFeature.STATE - | VacuumEntityFeature.STATUS + | VacuumEntityFeature.STOP | VacuumEntityFeature.TURN_OFF | VacuumEntityFeature.TURN_ON ) @@ -97,15 +101,48 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the cleaner on, starting a clean cycle.""" await self.robot.set_power_status(True) + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_turn_on", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_turn_on", + translation_placeholders={ + "old_service": "vacuum.turn_on", + "new_service": "vacuum.start", + }, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the unit off, stopping any cleaning in progress as is.""" await self.robot.set_power_status(False) + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_turn_off", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_turn_off", + translation_placeholders={ + "old_service": "vacuum.turn_off", + "new_service": "vacuum.stop", + }, + ) async def async_start(self) -> None: """Start a clean cycle.""" + await self.robot.set_power_status(True) await self.robot.start_cleaning() + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum cleaner.""" + await self.robot.set_power_status(False) + async def async_set_sleep_mode( self, enabled: bool, start_time: str | None = None ) -> None: diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index acc2ac80caa..ac95c6b0f0e 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==5.0.1"] + "requirements": ["ical==5.1.0"] } diff --git a/homeassistant/components/local_todo/__init__.py b/homeassistant/components/local_todo/__init__.py new file mode 100644 index 00000000000..f8403251ba0 --- /dev/null +++ b/homeassistant/components/local_todo/__init__.py @@ -0,0 +1,55 @@ +"""The Local To-do integration.""" +from __future__ import annotations + +from pathlib import Path + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import slugify + +from .const import CONF_STORAGE_KEY, CONF_TODO_LIST_NAME, DOMAIN +from .store import LocalTodoListStore + +PLATFORMS: list[Platform] = [Platform.TODO] + +STORAGE_PATH = ".storage/local_todo.{key}.ics" + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Local To-do from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + path = Path(hass.config.path(STORAGE_PATH.format(key=entry.data[CONF_STORAGE_KEY]))) + store = LocalTodoListStore(hass, path) + try: + await store.async_load() + except OSError as err: + raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err + + hass.data[DOMAIN][entry.entry_id] = store + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle removal of an entry.""" + key = slugify(entry.data[CONF_TODO_LIST_NAME]) + path = Path(hass.config.path(STORAGE_PATH.format(key=key))) + + def unlink(path: Path) -> None: + path.unlink(missing_ok=True) + + await hass.async_add_executor_job(unlink, path) diff --git a/homeassistant/components/local_todo/config_flow.py b/homeassistant/components/local_todo/config_flow.py new file mode 100644 index 00000000000..73328358a3c --- /dev/null +++ b/homeassistant/components/local_todo/config_flow.py @@ -0,0 +1,44 @@ +"""Config flow for Local To-do integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.util import slugify + +from .const import CONF_STORAGE_KEY, CONF_TODO_LIST_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_TODO_LIST_NAME): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Local To-do.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + key = slugify(user_input[CONF_TODO_LIST_NAME]) + self._async_abort_entries_match({CONF_STORAGE_KEY: key}) + user_input[CONF_STORAGE_KEY] = key + return self.async_create_entry( + title=user_input[CONF_TODO_LIST_NAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/local_todo/const.py b/homeassistant/components/local_todo/const.py new file mode 100644 index 00000000000..4677ed42178 --- /dev/null +++ b/homeassistant/components/local_todo/const.py @@ -0,0 +1,6 @@ +"""Constants for the Local To-do integration.""" + +DOMAIN = "local_todo" + +CONF_TODO_LIST_NAME = "todo_list_name" +CONF_STORAGE_KEY = "storage_key" diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json new file mode 100644 index 00000000000..049a1824495 --- /dev/null +++ b/homeassistant/components/local_todo/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "local_todo", + "name": "Local To-do", + "codeowners": ["@allenporter"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/local_todo", + "iot_class": "local_polling", + "requirements": ["ical==5.1.0"] +} diff --git a/homeassistant/components/local_todo/store.py b/homeassistant/components/local_todo/store.py new file mode 100644 index 00000000000..79d5adb217f --- /dev/null +++ b/homeassistant/components/local_todo/store.py @@ -0,0 +1,36 @@ +"""Local storage for the Local To-do integration.""" + +import asyncio +from pathlib import Path + +from homeassistant.core import HomeAssistant + + +class LocalTodoListStore: + """Local storage for a single To-do list.""" + + def __init__(self, hass: HomeAssistant, path: Path) -> None: + """Initialize LocalTodoListStore.""" + self._hass = hass + self._path = path + self._lock = asyncio.Lock() + + async def async_load(self) -> str: + """Load the calendar from disk.""" + async with self._lock: + return await self._hass.async_add_executor_job(self._load) + + def _load(self) -> str: + """Load the calendar from disk.""" + if not self._path.exists(): + return "" + return self._path.read_text() + + async def async_store(self, ics_content: str) -> None: + """Persist the calendar to storage.""" + async with self._lock: + await self._hass.async_add_executor_job(self._store, ics_content) + + def _store(self, ics_content: str) -> None: + """Persist the calendar to storage.""" + self._path.write_text(ics_content) diff --git a/homeassistant/components/local_todo/strings.json b/homeassistant/components/local_todo/strings.json new file mode 100644 index 00000000000..2403fae60a5 --- /dev/null +++ b/homeassistant/components/local_todo/strings.json @@ -0,0 +1,16 @@ +{ + "title": "Local To-do", + "config": { + "step": { + "user": { + "description": "Please choose a name for your new To-do list", + "data": { + "todo_list_name": "To-do list name" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py new file mode 100644 index 00000000000..7e23d01ee46 --- /dev/null +++ b/homeassistant/components/local_todo/todo.py @@ -0,0 +1,170 @@ +"""A Local To-do todo platform.""" + +from collections.abc import Iterable +import dataclasses +import logging +from typing import Any + +from ical.calendar import Calendar +from ical.calendar_stream import IcsCalendarStream +from ical.store import TodoStore +from ical.todo import Todo, TodoStatus +from pydantic import ValidationError + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_TODO_LIST_NAME, DOMAIN +from .store import LocalTodoListStore + +_LOGGER = logging.getLogger(__name__) + + +PRODID = "-//homeassistant.io//local_todo 1.0//EN" + +ICS_TODO_STATUS_MAP = { + TodoStatus.IN_PROCESS: TodoItemStatus.NEEDS_ACTION, + TodoStatus.NEEDS_ACTION: TodoItemStatus.NEEDS_ACTION, + TodoStatus.COMPLETED: TodoItemStatus.COMPLETED, + TodoStatus.CANCELLED: TodoItemStatus.COMPLETED, +} +ICS_TODO_STATUS_MAP_INV = { + TodoItemStatus.COMPLETED: TodoStatus.COMPLETED, + TodoItemStatus.NEEDS_ACTION: TodoStatus.NEEDS_ACTION, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the local_todo todo platform.""" + + store = hass.data[DOMAIN][config_entry.entry_id] + ics = await store.async_load() + calendar = IcsCalendarStream.calendar_from_ics(ics) + calendar.prodid = PRODID + + name = config_entry.data[CONF_TODO_LIST_NAME] + entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id) + async_add_entities([entity], True) + + +def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + """Convert TodoItem dataclass items to dictionary of attributes for ical consumption.""" + result: dict[str, str] = {} + for name, value in obj: + if name == "status": + result[name] = ICS_TODO_STATUS_MAP_INV[value] + elif value is not None: + result[name] = value + return result + + +def _convert_item(item: TodoItem) -> Todo: + """Convert a HomeAssistant TodoItem to an ical Todo.""" + try: + return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) + except ValidationError as err: + _LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) + raise HomeAssistantError("Error parsing todo input fields") from err + + +class LocalTodoListEntity(TodoListEntity): + """A To-do List representation of the Shopping List.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + ) + _attr_should_poll = False + + def __init__( + self, + store: LocalTodoListStore, + calendar: Calendar, + name: str, + unique_id: str, + ) -> None: + """Initialize LocalTodoListEntity.""" + self._store = store + self._calendar = calendar + self._attr_name = name.capitalize() + self._attr_unique_id = unique_id + + async def async_update(self) -> None: + """Update entity state based on the local To-do items.""" + self._attr_todo_items = [ + TodoItem( + uid=item.uid, + summary=item.summary or "", + status=ICS_TODO_STATUS_MAP.get( + item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION + ), + ) + for item in self._calendar.todos + ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + todo = _convert_item(item) + TodoStore(self._calendar).add(todo) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item to the To-do list.""" + todo = _convert_item(item) + TodoStore(self._calendar).edit(todo.uid, todo) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Add an item to the To-do list.""" + store = TodoStore(self._calendar) + for uid in uids: + store.delete(uid) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Re-order an item to the To-do list.""" + if uid == previous_uid: + return + todos = self._calendar.todos + item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)} + if uid not in item_idx: + raise HomeAssistantError( + "Item '{uid}' not found in todo list {self.entity_id}" + ) + if previous_uid and previous_uid not in item_idx: + raise HomeAssistantError( + "Item '{previous_uid}' not found in todo list {self.entity_id}" + ) + dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0 + src_idx = item_idx[uid] + src_item = todos.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + todos.insert(dst_idx, src_item) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def _async_save(self) -> None: + """Persist the todo list to disk.""" + content = IcsCalendarStream.calendar_to_ics(self._calendar) + await self._store.async_store(content) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 3ee65f751ae..cc43baab1c8 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -1,14 +1,17 @@ """The loqed integration.""" from __future__ import annotations +import asyncio import logging import re +import aiohttp from loqedAPI import loqed from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -27,12 +30,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: apiclient = loqed.APIClient(websession, f"http://{host}") api = loqed.LoqedAPI(apiclient) - lock = await api.async_get_lock( - entry.data["lock_key_key"], - entry.data["bridge_key"], - int(entry.data["lock_key_local_id"]), - re.sub(r"LOQED-([a-f0-9]+)\.local", r"\1", entry.data["bridge_mdns_hostname"]), - ) + try: + lock = await api.async_get_lock( + entry.data["lock_key_key"], + entry.data["bridge_key"], + int(entry.data["lock_key_local_id"]), + re.sub( + r"LOQED-([a-f0-9]+)\.local", r"\1", entry.data["bridge_mdns_hostname"] + ), + ) + except ( + asyncio.TimeoutError, + aiohttp.ClientError, + ) as ex: + raise ConfigEntryNotReady(f"Unable to connect to bridge at {host}") from ex coordinator = LoqedDataCoordinator(hass, api, lock, entry) await coordinator.ensure_webhooks() diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 911ccb0ff5b..1c76f480529 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import webhook from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_NAME, CONF_WEBHOOK_ID +from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -95,7 +95,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Check if already exists await self.async_set_unique_id(lock_data["bridge_mac_wifi"]) - self._abort_if_unique_id_configured({CONF_HOST: host}) + self._abort_if_unique_id_configured({"bridge_ip": host}) return await self.async_step_user() diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 1412aa085c8..2c425bec785 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -144,7 +144,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "yaml_dashboards": config[DOMAIN].get(CONF_DASHBOARDS, {}), } - if hass.config.safe_mode: + if hass.config.recovery_mode: return True async def storage_dashboard_changed(change_type, item_id, item): diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 6952a80a214..01110bb8a7c 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -3,13 +3,18 @@ from typing import Any import voluptuous as vol -from homeassistant.const import CONF_ICON, CONF_MODE, CONF_TYPE, CONF_URL +from homeassistant.const import ( + CONF_ICON, + CONF_MODE, + CONF_TYPE, + CONF_URL, + EVENT_LOVELACE_UPDATED, # noqa: F401 +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util import slugify DOMAIN = "lovelace" -EVENT_LOVELACE_UPDATED = "lovelace_updated" DEFAULT_ICON = "hass:view-dashboard" diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 054aaf9b24c..e1641451221 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -114,7 +114,7 @@ class LovelaceStorage(LovelaceConfig): async def async_load(self, force): """Load config.""" - if self.hass.config.safe_mode: + if self.hass.config.recovery_mode: raise ConfigNotFound if self._data is None: @@ -127,8 +127,8 @@ class LovelaceStorage(LovelaceConfig): async def async_save(self, config): """Save config.""" - if self.hass.config.safe_mode: - raise HomeAssistantError("Saving not supported in safe mode") + if self.hass.config.recovery_mode: + raise HomeAssistantError("Saving not supported in recovery mode") if self._data is None: await self._load() @@ -138,8 +138,8 @@ class LovelaceStorage(LovelaceConfig): async def async_delete(self): """Delete config.""" - if self.hass.config.safe_mode: - raise HomeAssistantError("Deleting not supported in safe mode") + if self.hass.config.recovery_mode: + raise HomeAssistantError("Deleting not supported in recovery mode") await self._store.async_remove() self._data = None diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index 423ba3117ea..b756c2765e1 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -60,6 +60,10 @@ async def websocket_lovelace_resources( """Send Lovelace UI resources over WebSocket configuration.""" resources = hass.data[DOMAIN]["resources"] + if hass.config.safe_mode: + connection.send_result(msg["id"], []) + return + if not resources.loaded: await resources.async_load() resources.loaded = True diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index da77aea6c4a..099275a98a1 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -3,7 +3,6 @@ from __future__ import annotations import datetime import logging -import re from typing import Any import voluptuous as vol @@ -280,7 +279,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): """Return one or more digits/characters.""" if self._code is None: return None - if isinstance(self._code, str) and re.search("^\\d+$", self._code): + if isinstance(self._code, str) and self._code.isdigit(): return alarm.CodeFormat.NUMBER return alarm.CodeFormat.TEXT diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 69cd1ef3d11..d1442a4e9ed 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -3,7 +3,6 @@ from __future__ import annotations import datetime import logging -import re from typing import Any import voluptuous as vol @@ -347,7 +346,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): """Return one or more digits/characters.""" if self._code is None: return None - if isinstance(self._code, str) and re.search("^\\d+$", self._code): + if isinstance(self._code, str) and self._code.isdigit(): return alarm.CodeFormat.NUMBER return alarm.CodeFormat.TEXT diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index cf7bcce7b3c..f9ef3593fe6 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Sequence import logging import mimetypes import os @@ -17,6 +18,7 @@ from nio.responses import ( JoinResponse, LoginError, Response, + RoomResolveAliasResponse, UploadError, UploadResponse, WhoamiError, @@ -53,6 +55,9 @@ CONF_COMMANDS = "commands" CONF_WORD = "word" CONF_EXPRESSION = "expression" +CONF_USERNAME_REGEX = "^@[^:]*:.*" +CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" + EVENT_MATRIX_COMMAND = "matrix_command" DEFAULT_CONTENT_TYPE = "application/octet-stream" @@ -65,7 +70,9 @@ ATTR_IMAGES = "images" # optional images WordCommand = NewType("WordCommand", str) ExpressionCommand = NewType("ExpressionCommand", re.Pattern) -RoomID = NewType("RoomID", str) +RoomAlias = NewType("RoomAlias", str) # Starts with "#" +RoomID = NewType("RoomID", str) # Starts with "!" +RoomAnyID = RoomID | RoomAlias class ConfigCommand(TypedDict, total=False): @@ -83,7 +90,9 @@ COMMAND_SCHEMA = vol.All( vol.Exclusive(CONF_WORD, "trigger"): cv.string, vol.Exclusive(CONF_EXPRESSION, "trigger"): cv.is_regex, vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ROOMS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ROOMS): vol.All( + cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)] + ), } ), cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION), @@ -95,10 +104,10 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_HOMESERVER): cv.url, vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Required(CONF_USERNAME): cv.matches_regex("@[^:]*:.*"), + vol.Required(CONF_USERNAME): cv.matches_regex(CONF_USERNAME_REGEX), vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_ROOMS, default=[]): vol.All( - cv.ensure_list, [cv.string] + cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)] ), vol.Optional(CONF_COMMANDS, default=[]): [COMMAND_SCHEMA], } @@ -116,7 +125,9 @@ SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( ), vol.Optional(ATTR_IMAGES): vol.All(cv.ensure_list, [cv.string]), }, - vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), + vol.Required(ATTR_TARGET): vol.All( + cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)] + ), } ) @@ -160,7 +171,7 @@ class MatrixBot: verify_ssl: bool, username: str, password: str, - listening_rooms: list[RoomID], + listening_rooms: list[RoomAnyID], commands: list[ConfigCommand], ) -> None: """Set up the client.""" @@ -178,11 +189,10 @@ class MatrixBot: homeserver=self._homeserver, user=self._mx_id, ssl=self._verify_tls ) - self._listening_rooms = listening_rooms - + self._listening_rooms: dict[RoomAnyID, RoomID] = {} self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {} self._expression_commands: dict[RoomID, list[ConfigCommand]] = {} - self._load_commands(commands) + self._unparsed_commands = commands async def stop_client(event: HassEvent) -> None: """Run once when Home Assistant stops.""" @@ -195,6 +205,8 @@ class MatrixBot: """Run once when Home Assistant finished startup.""" self._access_tokens = await self._get_auth_tokens() await self._login() + await self._resolve_room_aliases(listening_rooms) + self._load_commands(commands) await self._join_rooms() # Sync once so that we don't respond to past events. await self._client.sync(timeout=30_000) @@ -211,7 +223,7 @@ class MatrixBot: def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: # Set the command for all listening_rooms, unless otherwise specified. - command.setdefault(CONF_ROOMS, self._listening_rooms) # type: ignore[misc] + command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # type: ignore[misc] # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. if (word_command := command.get(CONF_WORD)) is not None: @@ -262,24 +274,60 @@ class MatrixBot: } self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) - async def _join_room(self, room_id_or_alias: str) -> None: + async def _resolve_room_alias( + self, room_alias_or_id: RoomAnyID + ) -> dict[RoomAnyID, RoomID]: + """Resolve a single RoomAlias if needed.""" + if room_alias_or_id.startswith("!"): + room_id = RoomID(room_alias_or_id) + _LOGGER.debug("Will listen to room_id '%s'", room_id) + elif room_alias_or_id.startswith("#"): + room_alias = RoomAlias(room_alias_or_id) + resolve_response = await self._client.room_resolve_alias(room_alias) + if isinstance(resolve_response, RoomResolveAliasResponse): + room_id = RoomID(resolve_response.room_id) + _LOGGER.debug( + "Will listen to room_alias '%s' as room_id '%s'", + room_alias_or_id, + room_id, + ) + else: + _LOGGER.error( + "Could not resolve '%s' to a room_id: '%s'", + room_alias_or_id, + resolve_response, + ) + return {} + # The config schema guarantees it's a valid room alias or id, so room_id is always set. + return {room_alias_or_id: room_id} + + async def _resolve_room_aliases(self, listening_rooms: list[RoomAnyID]) -> None: + """Resolve any RoomAliases into RoomIDs for the purpose of client interactions.""" + resolved_rooms = [ + self.hass.async_create_task(self._resolve_room_alias(room_alias_or_id)) + for room_alias_or_id in listening_rooms + ] + for resolved_room in asyncio.as_completed(resolved_rooms): + self._listening_rooms |= await resolved_room + + async def _join_room(self, room_id: RoomID, room_alias_or_id: RoomAnyID) -> None: """Join a room or do nothing if already joined.""" - join_response = await self._client.join(room_id_or_alias) + join_response = await self._client.join(room_id) if isinstance(join_response, JoinResponse): - _LOGGER.debug("Joined or already in room '%s'", room_id_or_alias) + _LOGGER.debug("Joined or already in room '%s'", room_alias_or_id) elif isinstance(join_response, JoinError): _LOGGER.error( "Could not join room '%s': %s", - room_id_or_alias, + room_alias_or_id, join_response, ) async def _join_rooms(self) -> None: """Join the Matrix rooms that we listen for commands in.""" rooms = [ - self.hass.async_create_task(self._join_room(room_id)) - for room_id in self._listening_rooms + self.hass.async_create_task(self._join_room(room_id, room_alias_or_id)) + for room_alias_or_id, room_id in self._listening_rooms.items() ] await asyncio.wait(rooms) @@ -356,11 +404,11 @@ class MatrixBot: await self._store_auth_token(self._client.access_token) async def _handle_room_send( - self, target_room: RoomID, message_type: str, content: dict + self, target_room: RoomAnyID, message_type: str, content: dict ) -> None: """Wrap _client.room_send and handle ErrorResponses.""" response: Response = await self._client.room_send( - room_id=target_room, + room_id=self._listening_rooms.get(target_room, target_room), message_type=message_type, content=content, ) @@ -374,7 +422,7 @@ class MatrixBot: _LOGGER.debug("Message delivered to room '%s'", target_room) async def _handle_multi_room_send( - self, target_rooms: list[RoomID], message_type: str, content: dict + self, target_rooms: Sequence[RoomAnyID], message_type: str, content: dict ) -> None: """Wrap _handle_room_send for multiple target_rooms.""" _tasks = [] @@ -390,7 +438,9 @@ class MatrixBot: ) await asyncio.wait(_tasks) - async def _send_image(self, image_path: str, target_rooms: list[RoomID]) -> None: + async def _send_image( + self, image_path: str, target_rooms: Sequence[RoomAnyID] + ) -> None: """Upload an image, then send it to all target_rooms.""" _is_allowed_path = await self.hass.async_add_executor_job( self.hass.config.is_allowed_path, image_path @@ -442,7 +492,7 @@ class MatrixBot: ) async def _send_message( - self, message: str, target_rooms: list[RoomID], data: dict | None + self, message: str, target_rooms: list[RoomAnyID], data: dict | None ) -> None: """Send a message to the Matrix server.""" content = {"msgtype": "m.text", "body": message} diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 69d059fdce5..a68741d4c33 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.1"] + "requirements": ["matrix-nio==0.22.1", "Pillow==10.1.0"] } diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 44e5d30fec4..a22f9174d2a 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -44,7 +44,7 @@ HVAC_SYSTEM_MODE_MAP = { } SystemModeEnum = clusters.Thermostat.Enums.ThermostatSystemMode ControlSequenceEnum = clusters.Thermostat.Enums.ThermostatControlSequence -ThermostatFeature = clusters.Thermostat.Bitmaps.ThermostatFeature +ThermostatFeature = clusters.Thermostat.Bitmaps.Feature class ThermostatRunningState(IntEnum): @@ -268,7 +268,7 @@ class MatterClimate(MatterEntity, ClimateEntity): @staticmethod def _create_optional_setpoint_command( - mode: clusters.Thermostat.Enums.SetpointAdjustMode, + mode: clusters.Thermostat.Enums.SetpointAdjustMode | int, target_temp: float, current_target_temp: float, ) -> clusters.Thermostat.Commands.SetpointRaiseLower | None: diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 84049301296..3361c3fa146 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -21,7 +21,7 @@ from .entity import MatterEntity from .helpers import get_matter from .models import MatterDiscoverySchema -SwitchFeature = clusters.Switch.Bitmaps.SwitchFeature +SwitchFeature = clusters.Switch.Bitmaps.Feature EVENT_TYPES_MAP = { # mapping from raw event id's to translation keys diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 2237f0ade98..6f494153a97 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==3.7.0"] + "requirements": ["python-matter-server==4.0.0"] } diff --git a/homeassistant/components/medcom_ble/strings.json b/homeassistant/components/medcom_ble/strings.json index 6ea6c0566ed..56cfb5a1dd7 100644 --- a/homeassistant/components/medcom_ble/strings.json +++ b/homeassistant/components/medcom_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 328871cf78c..39ce1f7a3bd 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -87,7 +87,7 @@ class MediaExtractor: def get_entities(self) -> list[str]: """Return list of entities.""" - return self.call_data.get(ATTR_ENTITY_ID, []) + return self.call_data.get(ATTR_ENTITY_ID, []) # type: ignore[no-any-return] def extract_and_send(self) -> None: """Extract exact stream format for each entity_id and play it.""" diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 16bfc93f715..53764252043 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -68,6 +69,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + await cleanup_old_device(hass) + return True @@ -88,6 +91,15 @@ async def async_update_entry(hass: HomeAssistant, config_entry: ConfigEntry): await hass.config_entries.async_reload(config_entry.entry_id) +async def cleanup_old_device(hass: HomeAssistant) -> None: + """Cleanup device without proper device identifier.""" + device_reg = dr.async_get(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN,)}) # type: ignore[arg-type] + if device: + _LOGGER.debug("Removing improper device %s", device.name) + device_reg.async_remove_device(device.id) + + class CannotConnect(HomeAssistantError): """Unable to connect to the web site.""" diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index d6466bb64c4..a3190109cac 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -1,7 +1,7 @@ { "domain": "met", "name": "Meteorologisk institutt (Met.no)", - "codeowners": ["@danielhiversen", "@thimic"], + "codeowners": ["@danielhiversen"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/met", "iot_class": "cloud_polling", diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index a1cc1ade8e1..8a5c405c1c1 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -55,12 +55,12 @@ async def async_setup_entry( is_metric = hass.config.units is METRIC_SYSTEM if config_entry.data.get(CONF_TRACK_HOME, False): name = hass.config.location_name - elif (name := config_entry.data.get(CONF_NAME)) and name is None: - name = DEFAULT_NAME - elif TYPE_CHECKING: - assert isinstance(name, str) + else: + name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) + if TYPE_CHECKING: + assert isinstance(name, str) - entities = [MetWeather(coordinator, config_entry.data, False, name, is_metric)] + entities = [MetWeather(coordinator, config_entry, False, name, is_metric)] # Add hourly entity to legacy config entries if entity_registry.async_get_entity_id( @@ -69,9 +69,7 @@ async def async_setup_entry( _calculate_unique_id(config_entry.data, True), ): name = f"{name} hourly" - entities.append( - MetWeather(coordinator, config_entry.data, True, name, is_metric) - ) + entities.append(MetWeather(coordinator, config_entry, True, name, is_metric)) async_add_entities(entities) @@ -114,22 +112,22 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): def __init__( self, coordinator: MetDataUpdateCoordinator, - config: MappingProxyType[str, Any], + config_entry: ConfigEntry, hourly: bool, name: str, is_metric: bool, ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) - self._attr_unique_id = _calculate_unique_id(config, hourly) - self._config = config + self._attr_unique_id = _calculate_unique_id(config_entry.data, hourly) + self._config = config_entry.data self._is_metric = is_metric self._hourly = hourly self._attr_entity_registry_enabled_default = not hourly self._attr_device_info = DeviceInfo( name="Forecast", entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, # type: ignore[arg-type] + identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer="Met.no", model="Forecast", configuration_url="https://www.met.no/en", diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 56bf5ee99ce..e00215f6073 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -4,10 +4,9 @@ from __future__ import annotations import asyncio import logging import re +import sys from typing import Any -import datapoint - from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, @@ -17,10 +16,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from .const import ( DEFAULT_SCAN_INTERVAL, @@ -35,6 +34,9 @@ from .const import ( from .data import MetOfficeData from .helpers import fetch_data, fetch_site +if sys.version_info < (3, 12): + import datapoint + _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.WEATHER] @@ -42,6 +44,10 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Met Office entry.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Met Office is not supported on Python 3.12. Please use Python 3.11." + ) latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] @@ -105,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: fetch_data, connection, site, MODE_DAILY ) - metoffice_hourly_coordinator = DataUpdateCoordinator( + metoffice_hourly_coordinator = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"MetOffice Hourly Coordinator for {site_name}", @@ -113,7 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=DEFAULT_SCAN_INTERVAL, ) - metoffice_daily_coordinator = DataUpdateCoordinator( + metoffice_daily_coordinator = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"MetOffice Daily Coordinator for {site_name}", diff --git a/homeassistant/components/metoffice/data.py b/homeassistant/components/metoffice/data.py index 4b2741ce0fb..8512dd4c7a6 100644 --- a/homeassistant/components/metoffice/data.py +++ b/homeassistant/components/metoffice/data.py @@ -1,11 +1,13 @@ """Common Met Office Data class used by both sensor and entity.""" - +from __future__ import annotations from dataclasses import dataclass +import sys -from datapoint.Forecast import Forecast -from datapoint.Site import Site -from datapoint.Timestep import Timestep +if sys.version_info < (3, 12): + from datapoint.Forecast import Forecast + from datapoint.Site import Site + from datapoint.Timestep import Timestep @dataclass diff --git a/homeassistant/components/metoffice/helpers.py b/homeassistant/components/metoffice/helpers.py index cdd506790ef..389462d573a 100644 --- a/homeassistant/components/metoffice/helpers.py +++ b/homeassistant/components/metoffice/helpers.py @@ -2,9 +2,7 @@ from __future__ import annotations import logging - -import datapoint -from datapoint.Site import Site +import sys from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util.dt import utcnow @@ -12,6 +10,11 @@ from homeassistant.util.dt import utcnow from .const import MODE_3HOURLY from .data import MetOfficeData +if sys.version_info < (3, 12): + import datapoint + from datapoint.Site import Site + + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/metoffice/manifest.json b/homeassistant/components/metoffice/manifest.json index cfe6e6de9cd..9291f22f3b7 100644 --- a/homeassistant/components/metoffice/manifest.json +++ b/homeassistant/components/metoffice/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/metoffice", "iot_class": "cloud_polling", "loggers": ["datapoint"], - "requirements": ["datapoint==0.9.8"] + "requirements": ["datapoint==0.9.8;python_version<'3.12'"] } diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 0b4672ddec8..b6e35168276 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -1,7 +1,7 @@ """Support for UK Met Office weather service.""" from __future__ import annotations -from typing import Any +from typing import Any, cast from datapoint.Timestep import Timestep @@ -11,17 +11,17 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_WIND_BEARING, + DOMAIN as WEATHER_DOMAIN, + CoordinatorWeatherEntity, Forecast, - WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from . import get_device_info from .const import ( @@ -41,15 +41,34 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Met Office weather sensor platform.""" + entity_registry = er.async_get(hass) hass_data = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - MetOfficeWeather(hass_data[METOFFICE_HOURLY_COORDINATOR], hass_data, True), - MetOfficeWeather(hass_data[METOFFICE_DAILY_COORDINATOR], hass_data, False), - ], - False, - ) + entities = [ + MetOfficeWeather( + hass_data[METOFFICE_DAILY_COORDINATOR], + hass_data[METOFFICE_HOURLY_COORDINATOR], + hass_data, + False, + ) + ] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(hass_data[METOFFICE_COORDINATES], True), + ): + entities.append( + MetOfficeWeather( + hass_data[METOFFICE_DAILY_COORDINATOR], + hass_data[METOFFICE_HOURLY_COORDINATOR], + hass_data, + True, + ) + ) + + async_add_entities(entities, False) def _build_forecast_data(timestep: Timestep) -> Forecast: @@ -67,8 +86,20 @@ def _build_forecast_data(timestep: Timestep) -> Forecast: return data +def _calculate_unique_id(coordinates: str, use_3hourly: bool) -> str: + """Calculate unique ID.""" + if use_3hourly: + return coordinates + return f"{coordinates}_{MODE_DAILY}" + + class MetOfficeWeather( - CoordinatorEntity[DataUpdateCoordinator[MetOfficeData]], WeatherEntity + CoordinatorWeatherEntity[ + TimestampDataUpdateCoordinator[MetOfficeData], + TimestampDataUpdateCoordinator[MetOfficeData], + TimestampDataUpdateCoordinator[MetOfficeData], + TimestampDataUpdateCoordinator[MetOfficeData], # Can be removed in Python 3.12 + ] ): """Implementation of a Met Office weather condition.""" @@ -78,23 +109,36 @@ class MetOfficeWeather( _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_DAILY + ) def __init__( self, - coordinator: DataUpdateCoordinator[MetOfficeData], + coordinator_daily: TimestampDataUpdateCoordinator[MetOfficeData], + coordinator_hourly: TimestampDataUpdateCoordinator[MetOfficeData], hass_data: dict[str, Any], use_3hourly: bool, ) -> None: """Initialise the platform with a data instance.""" - super().__init__(coordinator) + self._hourly = use_3hourly + if use_3hourly: + observation_coordinator = coordinator_hourly + else: + observation_coordinator = coordinator_daily + super().__init__( + observation_coordinator, + daily_coordinator=coordinator_daily, + hourly_coordinator=coordinator_hourly, + ) self._attr_device_info = get_device_info( coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME] ) self._attr_name = "3-Hourly" if use_3hourly else "Daily" - self._attr_unique_id = hass_data[METOFFICE_COORDINATES] - if not use_3hourly: - self._attr_unique_id = f"{self._attr_unique_id}_{MODE_DAILY}" + self._attr_unique_id = _calculate_unique_id( + hass_data[METOFFICE_COORDINATES], use_3hourly + ) @property def condition(self) -> str | None: @@ -155,3 +199,25 @@ class MetOfficeWeather( _build_forecast_data(timestep) for timestep in self.coordinator.data.forecast ] + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + coordinator = cast( + TimestampDataUpdateCoordinator[MetOfficeData], + self.forecast_coordinators["daily"], + ) + return [ + _build_forecast_data(timestep) for timestep in coordinator.data.forecast + ] + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + coordinator = cast( + TimestampDataUpdateCoordinator[MetOfficeData], + self.forecast_coordinators["hourly"], + ) + return [ + _build_forecast_data(timestep) for timestep in coordinator.data.forecast + ] diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 7f2b08c96ef..4e5ab9290f0 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -4,14 +4,21 @@ from __future__ import annotations import logging from typing import Any -from mcstatus import JavaServer - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform +from homeassistant.const import ( + CONF_ADDRESS, + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_TYPE, + Platform, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er +from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD from .coordinator import MinecraftServerCoordinator @@ -23,8 +30,20 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" + # Check and create API instance. + try: + api = await hass.async_add_executor_job( + MinecraftServer, + entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + entry.data[CONF_ADDRESS], + ) + except MinecraftServerAddressError as error: + raise ConfigEntryError( + f"Server address in configuration entry is invalid: {error}" + ) from error + # Create coordinator instance. - coordinator = MinecraftServerCoordinator(hass, entry) + coordinator = MinecraftServerCoordinator(hass, entry.data[CONF_NAME], api) await coordinator.async_config_entry_first_refresh() # Store coordinator instance. @@ -85,22 +104,22 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Migrate config entry. try: address = config_data[CONF_HOST] - JavaServer.lookup(address) + MinecraftServer(MinecraftServerType.JAVA_EDITION, address) host_only_lookup_success = True - except ValueError as error: + except MinecraftServerAddressError as error: host_only_lookup_success = False _LOGGER.debug( - "Hostname (without port) cannot be parsed (error: %s), trying again with port", + "Hostname (without port) cannot be parsed, trying again with port: %s", error, ) if not host_only_lookup_success: try: address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" - JavaServer.lookup(address) - except ValueError as error: + MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + except MinecraftServerAddressError as error: _LOGGER.exception( - "Can't migrate configuration entry due to error while parsing server address (error: %s), try again later", + "Can't migrate configuration entry due to error while parsing server address, try again later: %s", error, ) return False diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py new file mode 100644 index 00000000000..4ab7865f369 --- /dev/null +++ b/homeassistant/components/minecraft_server/api.py @@ -0,0 +1,149 @@ +"""API for the Minecraft Server integration.""" + + +from dataclasses import dataclass +from enum import StrEnum +import logging + +from dns.resolver import LifetimeTimeout +from mcstatus import BedrockServer, JavaServer +from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse + +_LOGGER = logging.getLogger(__name__) + +LOOKUP_TIMEOUT: float = 10 +DATA_UPDATE_TIMEOUT: float = 10 +DATA_UPDATE_RETRIES: int = 3 + + +@dataclass +class MinecraftServerData: + """Representation of Minecraft Server data.""" + + # Common data + latency: float + motd: str + players_max: int + players_online: int + protocol_version: int + version: str + + # Data available only in 'Java Edition' + players_list: list[str] | None = None + + # Data available only in 'Bedrock Edition' + edition: str | None = None + game_mode: str | None = None + map_name: str | None = None + + +class MinecraftServerType(StrEnum): + """Enumeration of Minecraft Server types.""" + + BEDROCK_EDITION = "Bedrock Edition" + JAVA_EDITION = "Java Edition" + + +class MinecraftServerAddressError(Exception): + """Raised when the input address is invalid.""" + + +class MinecraftServerConnectionError(Exception): + """Raised when no data can be fechted from the server.""" + + +class MinecraftServer: + """Minecraft Server wrapper class for 3rd party library mcstatus.""" + + _server: BedrockServer | JavaServer + + def __init__(self, server_type: MinecraftServerType, address: str) -> None: + """Initialize server instance.""" + try: + if server_type == MinecraftServerType.JAVA_EDITION: + self._server = JavaServer.lookup(address, timeout=LOOKUP_TIMEOUT) + else: + self._server = BedrockServer.lookup(address, timeout=LOOKUP_TIMEOUT) + except (ValueError, LifetimeTimeout) as error: + raise MinecraftServerAddressError( + f"Lookup of '{address}' failed: {self._get_error_message(error)}" + ) from error + + self._server.timeout = DATA_UPDATE_TIMEOUT + self._address = address + + _LOGGER.debug( + "%s server instance created with address '%s'", server_type, address + ) + + async def async_is_online(self) -> bool: + """Check if the server is online, supporting both Java and Bedrock Edition servers.""" + try: + await self.async_get_data() + except MinecraftServerConnectionError: + return False + + return True + + async def async_get_data(self) -> MinecraftServerData: + """Get updated data from the server, supporting both Java and Bedrock Edition servers.""" + status_response: BedrockStatusResponse | JavaStatusResponse + + try: + status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) + except OSError as error: + raise MinecraftServerConnectionError( + f"Status request to '{self._address}' failed: {self._get_error_message(error)}" + ) from error + + if isinstance(status_response, JavaStatusResponse): + data = self._extract_java_data(status_response) + else: + data = self._extract_bedrock_data(status_response) + + return data + + def _extract_java_data( + self, status_response: JavaStatusResponse + ) -> MinecraftServerData: + """Extract Java Edition server data out of status response.""" + players_list = [] + + if players := status_response.players.sample: + for player in players: + players_list.append(player.name) + players_list.sort() + + return MinecraftServerData( + latency=status_response.latency, + motd=status_response.motd.to_plain(), + players_max=status_response.players.max, + players_online=status_response.players.online, + protocol_version=status_response.version.protocol, + version=status_response.version.name, + players_list=players_list, + ) + + def _extract_bedrock_data( + self, status_response: BedrockStatusResponse + ) -> MinecraftServerData: + """Extract Bedrock Edition server data out of status response.""" + return MinecraftServerData( + latency=status_response.latency, + motd=status_response.motd.to_plain(), + players_max=status_response.players.max, + players_online=status_response.players.online, + protocol_version=status_response.version.protocol, + version=status_response.version.name, + edition=status_response.version.brand, + game_mode=status_response.gamemode, + map_name=status_response.map_name, + ) + + def _get_error_message(self, error: BaseException) -> str: + """Get error message of an exception.""" + if not str(error): + # Fallback to error type in case of an empty error message. + return repr(error) + + return str(error) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index e89fce2d7d5..520d7342b35 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -45,7 +45,7 @@ async def async_setup_entry( # Add binary sensor entities. async_add_entities( [ - MinecraftServerBinarySensorEntity(coordinator, description) + MinecraftServerBinarySensorEntity(coordinator, description, config_entry) for description in BINARY_SENSOR_DESCRIPTIONS ] ) @@ -60,11 +60,12 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit self, coordinator: MinecraftServerCoordinator, description: MinecraftServerBinarySensorEntityDescription, + config_entry: ConfigEntry, ) -> None: """Initialize binary sensor base entity.""" - super().__init__(coordinator) + super().__init__(coordinator, config_entry) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_is_on = False @property diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 527dfa1ed04..f064a4ac1ef 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,13 +1,13 @@ """Config flow for Minecraft Server integration.""" import logging -from mcstatus import JavaServer import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.data_entry_flow import FlowResult +from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DEFAULT_NAME, DOMAIN DEFAULT_ADDRESS = "localhost:25565" @@ -27,10 +27,28 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: address = user_input[CONF_ADDRESS] - if await self._async_is_server_online(address): - # No error was detected, create configuration entry. - config_data = {CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address} - return self.async_create_entry(title=address, data=config_data) + # Prepare config entry data. + config_data = { + CONF_NAME: user_input[CONF_NAME], + CONF_ADDRESS: address, + } + + # Some Bedrock Edition servers mimic a Java Edition server, therefore check for a Bedrock Edition server first. + for server_type in MinecraftServerType: + try: + api = await self.hass.async_add_executor_job( + MinecraftServer, server_type, address + ) + except MinecraftServerAddressError: + pass + else: + if await api.async_is_online(): + config_data[CONF_TYPE] = server_type + return self.async_create_entry(title=address, data=config_data) + + _LOGGER.debug( + "Connection check to %s server '%s' failed", server_type, address + ) # Host or port invalid or server not reachable. errors["base"] = "cannot_connect" @@ -59,37 +77,3 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) - - async def _async_is_server_online(self, address: str) -> bool: - """Check server connection using a 'status' request and return result.""" - - # Parse and check server address. - try: - server = await JavaServer.async_lookup(address) - except ValueError as error: - _LOGGER.debug( - ( - "Error occurred while parsing server address '%s' -" - " ValueError: %s" - ), - address, - error, - ) - return False - - # Send a status request to the server. - try: - await server.async_status() - return True - except OSError as error: - _LOGGER.debug( - ( - "Error occurred while trying to check the connection to '%s:%s' -" - " OSError: %s" - ), - server.address.host, - server.address.port, - error, - ) - - return False diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 9b5ab1fbb43..f7a60318c64 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -1,77 +1,36 @@ """The Minecraft Server integration.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from mcstatus.server import JavaServer - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .api import MinecraftServer, MinecraftServerConnectionError, MinecraftServerData + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) -@dataclass -class MinecraftServerData: - """Representation of Minecraft Server data.""" - - latency: float - motd: str - players_max: int - players_online: int - players_list: list[str] - protocol_version: int - version: str - - class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Minecraft Server data update coordinator.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, name: str, api: MinecraftServer) -> None: """Initialize coordinator instance.""" - config_data = config_entry.data - self.unique_id = config_entry.entry_id + self._api = api super().__init__( hass=hass, - name=config_data[CONF_NAME], + name=name, logger=_LOGGER, update_interval=SCAN_INTERVAL, ) - try: - self._server = JavaServer.lookup(config_data[CONF_ADDRESS]) - except ValueError as error: - raise HomeAssistantError( - f"Address in configuration entry cannot be parsed (error: {error}), please remove this device and add it again" - ) from error - async def _async_update_data(self) -> MinecraftServerData: - """Get server data from 3rd party library and update properties.""" + """Get updated data from the server.""" try: - status_response = await self._server.async_status() - except OSError as error: + return await self._api.async_get_data() + except MinecraftServerConnectionError as error: raise UpdateFailed(error) from error - - players_list = [] - if players := status_response.players.sample: - for player in players: - players_list.append(player.name) - players_list.sort() - - return MinecraftServerData( - version=status_response.version.name, - protocol_version=status_response.version.protocol, - players_online=status_response.players.online, - players_max=status_response.players.max, - players_list=players_list, - latency=status_response.latency, - motd=status_response.motd.to_plain(), - ) diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py new file mode 100644 index 00000000000..62e507ef09f --- /dev/null +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics for the Minecraft Server integration.""" +from collections.abc import Iterable +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + "config_entry": { + "version": config_entry.version, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + "config_entry_data": async_redact_data(config_entry.data, TO_REDACT), + "config_entry_options": async_redact_data(config_entry.options, TO_REDACT), + "server_data": async_redact_data(asdict(coordinator.data), TO_REDACT), + } diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 9bac71e0000..9a94fb4e168 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,8 +1,11 @@ """Base entity for the Minecraft Server integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .api import MinecraftServerType from .const import DOMAIN from .coordinator import MinecraftServerCoordinator @@ -17,13 +20,15 @@ class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): def __init__( self, coordinator: MinecraftServerCoordinator, + config_entry: ConfigEntry, ) -> None: """Initialize base entity.""" super().__init__(coordinator) + self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.unique_id)}, + identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({coordinator.data.version})", + model=f"Minecraft Server ({config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION)})", name=coordinator.name, - sw_version=str(coordinator.data.protocol_version), + sw_version=f"{coordinator.data.version} ({coordinator.data.protocol_version})", ) diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 6f11d34cccb..73a7dc18d09 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/minecraft_server", "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], - "quality_scale": "silver", + "quality_scale": "gold", "requirements": ["mcstatus==11.0.0"] } diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index efe534e0f92..661ce00dac5 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -7,17 +7,21 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTime +from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from .api import MinecraftServerData, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD -from .coordinator import MinecraftServerCoordinator, MinecraftServerData +from .coordinator import MinecraftServerCoordinator from .entity import MinecraftServerEntity ATTR_PLAYERS_LIST = "players_list" +ICON_EDITION = "mdi:minecraft" +ICON_GAME_MODE = "mdi:cog" +ICON_MAP_NAME = "mdi:map" ICON_LATENCY = "mdi:signal" ICON_PLAYERS_MAX = "mdi:account-multiple" ICON_PLAYERS_ONLINE = "mdi:account-multiple" @@ -25,6 +29,9 @@ ICON_PROTOCOL_VERSION = "mdi:numeric" ICON_VERSION = "mdi:numeric" ICON_MOTD = "mdi:minecraft" +KEY_EDITION = "edition" +KEY_GAME_MODE = "game_mode" +KEY_MAP_NAME = "map_name" KEY_PLAYERS_MAX = "players_max" KEY_PLAYERS_ONLINE = "players_online" KEY_PROTOCOL_VERSION = "protocol_version" @@ -40,6 +47,7 @@ class MinecraftServerEntityDescriptionMixin: value_fn: Callable[[MinecraftServerData], StateType] attributes_fn: Callable[[MinecraftServerData], MutableMapping[str, Any]] | None + supported_server_types: set[MinecraftServerType] @dataclass @@ -69,6 +77,11 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_VERSION, value_fn=lambda data: data.version, attributes_fn=None, + supported_server_types={ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + }, + entity_category=EntityCategory.DIAGNOSTIC, ), MinecraftServerSensorEntityDescription( key=KEY_PROTOCOL_VERSION, @@ -76,6 +89,12 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PROTOCOL_VERSION, value_fn=lambda data: data.protocol_version, attributes_fn=None, + supported_server_types={ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + }, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), MinecraftServerSensorEntityDescription( key=KEY_PLAYERS_MAX, @@ -84,6 +103,11 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PLAYERS_MAX, value_fn=lambda data: data.players_max, attributes_fn=None, + supported_server_types={ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + }, + entity_registry_enabled_default=False, ), MinecraftServerSensorEntityDescription( key=KEY_LATENCY, @@ -93,6 +117,11 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_LATENCY, value_fn=lambda data: data.latency, attributes_fn=None, + supported_server_types={ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + }, + entity_category=EntityCategory.DIAGNOSTIC, ), MinecraftServerSensorEntityDescription( key=KEY_MOTD, @@ -100,6 +129,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_MOTD, value_fn=lambda data: data.motd, attributes_fn=None, + supported_server_types={ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + }, ), MinecraftServerSensorEntityDescription( key=KEY_PLAYERS_ONLINE, @@ -108,6 +141,42 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PLAYERS_ONLINE, value_fn=lambda data: data.players_online, attributes_fn=get_extra_state_attributes_players_list, + supported_server_types={ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + }, + ), + MinecraftServerSensorEntityDescription( + key=KEY_EDITION, + translation_key=KEY_EDITION, + icon=ICON_EDITION, + value_fn=lambda data: data.edition, + attributes_fn=None, + supported_server_types={ + MinecraftServerType.BEDROCK_EDITION, + }, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + MinecraftServerSensorEntityDescription( + key=KEY_GAME_MODE, + translation_key=KEY_GAME_MODE, + icon=ICON_GAME_MODE, + value_fn=lambda data: data.game_mode, + attributes_fn=None, + supported_server_types={ + MinecraftServerType.BEDROCK_EDITION, + }, + ), + MinecraftServerSensorEntityDescription( + key=KEY_MAP_NAME, + translation_key=KEY_MAP_NAME, + icon=ICON_MAP_NAME, + value_fn=lambda data: data.map_name, + attributes_fn=None, + supported_server_types={ + MinecraftServerType.BEDROCK_EDITION, + }, ), ] @@ -123,8 +192,10 @@ async def async_setup_entry( # Add sensor entities. async_add_entities( [ - MinecraftServerSensorEntity(coordinator, description) + MinecraftServerSensorEntity(coordinator, description, config_entry) for description in SENSOR_DESCRIPTIONS + if config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION) + in description.supported_server_types ] ) @@ -138,11 +209,12 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): self, coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, + config_entry: ConfigEntry, ) -> None: """Initialize sensor base entity.""" - super().__init__(coordinator) + super().__init__(coordinator, config_entry) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._update_properties() @callback diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index c5fe5b81d81..622a45a5aeb 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -11,7 +11,7 @@ } }, "error": { - "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. Also ensure that you are running at least version 1.7 of Minecraft Java Edition on your server." + "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version 1.7." } }, "entity": { @@ -38,6 +38,15 @@ }, "motd": { "name": "World message" + }, + "game_mode": { + "name": "Game mode" + }, + "map_name": { + "name": "Map name" + }, + "edition": { + "name": "Edition" } } } diff --git a/homeassistant/components/minio/strings.json b/homeassistant/components/minio/strings.json index 75b8375adb1..68a4786bc63 100644 --- a/homeassistant/components/minio/strings.json +++ b/homeassistant/components/minio/strings.json @@ -9,7 +9,7 @@ "description": "Bucket to use." }, "key": { - "name": "Kay", + "name": "Key", "description": "Object key of the file." }, "file_path": { diff --git a/homeassistant/components/moat/strings.json b/homeassistant/components/moat/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/moat/strings.json +++ b/homeassistant/components/moat/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 85fba66b68a..a2b0c24464c 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -232,8 +232,8 @@ CLIMATE_SCHEMA = vol.All( { vol.Required(CONF_TARGET_TEMP): cv.positive_int, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, - vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_int, - vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, + vol.Optional(CONF_MAX_TEMP, default=35): number_validator, + vol.Optional(CONF_MIN_TEMP, default=5): number_validator, vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index ee98b51b72a..edfca94979e 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -194,22 +194,25 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): registers.reverse() return registers - def __process_raw_value( - self, entry: float | int | str | bytes - ) -> float | int | str | bytes | None: + def __process_raw_value(self, entry: float | int | str | bytes) -> str | None: """Process value from sensor with NaN handling, scaling, offset, min/max etc.""" if self._nan_value and entry in (self._nan_value, -self._nan_value): return None if isinstance(entry, bytes): - return entry + return entry.decode() + if entry != entry: # noqa: PLR0124 + # NaN float detection replace with None + return None val: float | int = self._scale * entry + self._offset if self._min_value is not None and val < self._min_value: - return self._min_value + return str(self._min_value) if self._max_value is not None and val > self._max_value: - return self._max_value + return str(self._max_value) if self._zero_suppress is not None and abs(val) <= self._zero_suppress: - return 0 - return val + return "0" + if self._precision == 0: + return str(int(round(val, 0))) + return f"{float(val):.{self._precision}f}" def unpack_structure_result(self, registers: list[int]) -> str | None: """Convert registers to proper result.""" @@ -219,6 +222,8 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) if self._data_type == DataType.STRING: return byte_string.decode() + if byte_string == b"nan\x00": + return None try: val = struct.unpack(self._structure, byte_string) @@ -227,49 +232,19 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): msg = f"Received {recv_size} bytes, unpack error {err}" _LOGGER.error(msg) return None - # Issue: https://github.com/home-assistant/core/issues/41944 - # If unpack() returns a tuple greater than 1, don't try to process the value. - # Instead, return the values of unpack(...) separated by commas. if len(val) > 1: # Apply scale, precision, limits to floats and ints v_result = [] for entry in val: v_temp = self.__process_raw_value(entry) - - # We could convert int to float, and the code would still work; however - # we lose some precision, and unit tests will fail. Therefore, we do - # the conversion only when it's absolutely necessary. - if isinstance(v_temp, int) and self._precision == 0: - v_result.append(str(v_temp)) - elif v_temp is None: - v_result.append("0") - elif v_temp != v_temp: # noqa: PLR0124 - # NaN float detection replace with None + if v_temp is None: v_result.append("0") else: - v_result.append(f"{float(v_temp):.{self._precision}f}") + v_result.append(str(v_temp)) return ",".join(map(str, v_result)) - # NaN float detection replace with None - if val[0] != val[0]: # noqa: PLR0124 - return None - if byte_string == b"nan\x00": - return None - # Apply scale, precision, limits to floats and ints - val_result = self.__process_raw_value(val[0]) - - # We could convert int to float, and the code would still work; however - # we lose some precision, and unit tests will fail. Therefore, we do - # the conversion only when it's absolutely necessary. - - if val_result is None: - return None - if isinstance(val_result, int) and self._precision == 0: - return str(val_result) - if isinstance(val_result, bytes): - return val_result.decode() - return f"{float(val_result):.{self._precision}f}" + return self.__process_raw_value(val[0]) class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 7faf873b655..93a3f22c97d 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.5.2"] + "requirements": ["pymodbus==3.5.4"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 4ef205aace3..764cf4930f7 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -443,7 +443,7 @@ class ModbusHub: if not hasattr(result, entry.attr): self._log_error(str(result)) return None - if result.isError(): # type: ignore[no-untyped-call] + if result.isError(): self._log_error("Error: pymodbus returned isError True") return None self._in_error = False diff --git a/homeassistant/components/mopeka/strings.json b/homeassistant/components/mopeka/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/mopeka/strings.json +++ b/homeassistant/components/mopeka/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 7caeb2b51f7..be283271dee 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -29,9 +29,14 @@ from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.issue_registry import ( + async_delete_issue, + async_get as async_get_issue_registry, +) from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_integration # Loading the config flow file will register the flow from . import debug_info, discovery @@ -42,6 +47,7 @@ from .client import ( # noqa: F401 publish, subscribe, ) +from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401 from .config_integration import CONFIG_SCHEMA_BASE from .const import ( # noqa: F401 ATTR_PAYLOAD, @@ -209,6 +215,40 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - await hass.config_entries.async_reload(entry.entry_id) +@callback +def _async_remove_mqtt_issues(hass: HomeAssistant, mqtt_data: MqttData) -> None: + """Unregister open config issues.""" + issue_registry = async_get_issue_registry(hass) + open_issues = [ + issue_id + for (domain, issue_id), issue_entry in issue_registry.issues.items() + if domain == DOMAIN and issue_entry.translation_key == "invalid_platform_config" + ] + for issue in open_issues: + async_delete_issue(hass, DOMAIN, issue) + + +async def async_check_config_schema( + hass: HomeAssistant, config_yaml: ConfigType +) -> None: + """Validate manually configured MQTT items.""" + mqtt_data = get_mqtt_data(hass) + mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml.get(DOMAIN, {}) + for mqtt_config_item in mqtt_config: + for domain, config_items in mqtt_config_item.items(): + schema = mqtt_data.reload_schema[domain] + for config in config_items: + try: + schema(config) + except vol.Invalid as ex: + integration = await async_get_integration(hass, DOMAIN) + # pylint: disable-next=protected-access + message, _ = conf_util._format_config_error( + ex, domain, config, integration.documentation + ) + raise HomeAssistantError(message) from ex + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" conf: dict[str, Any] @@ -373,6 +413,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Error reloading manually configured MQTT items, " "check your configuration.yaml" ) + # Check the schema before continuing reload + await async_check_config_schema(hass, config_yaml) + + # Remove repair issues + _async_remove_mqtt_issues(hass, mqtt_data) + mqtt_data.config = config_yaml.get(DOMAIN, {}) # Reload the modern yaml platforms @@ -381,22 +427,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entity.async_remove() for mqtt_platform in mqtt_platforms for entity in mqtt_platform.entities.values() - # pylint: disable-next=protected-access - if not entity._discovery_data # type: ignore[attr-defined] - if mqtt_platform.config_entry + if getattr(entity, "_discovery_data", None) is None + and mqtt_platform.config_entry and mqtt_platform.domain in RELOADABLE_PLATFORMS ] await asyncio.gather(*tasks) - await asyncio.gather( - *( - [ - mqtt_data.reload_handlers[component]() - for component in RELOADABLE_PLATFORMS - if component in mqtt_data.reload_handlers - ] - ) - ) + for _, component in mqtt_data.reload_handlers.items(): + component() # Fire event hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) @@ -594,4 +632,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if subscriptions := mqtt_client.subscriptions: mqtt_data.subscriptions_to_restore = subscriptions + # Remove repair issues + _async_remove_mqtt_issues(hass, mqtt_data) + return True diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 3600d9663dd..68aca18f249 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -1,9 +1,7 @@ """Control a MQTT alarm.""" from __future__ import annotations -import functools import logging -import re import voluptuous as vol @@ -28,7 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA @@ -45,7 +43,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -134,21 +132,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT alarm control panel through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttAlarm, + alarm.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, alarm.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Alarm Control Panel platform.""" - async_add_entities([MqttAlarm(hass, config, config_entry, discovery_data)]) class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): @@ -178,9 +170,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): if (code := self._config.get(CONF_CODE)) is None: self._attr_code_format = None - elif code == REMOTE_CODE or ( - isinstance(code, str) and re.search("^\\d+$", code) - ): + elif code == REMOTE_CODE or str(code).isdigit(): self._attr_code_format = alarm.CodeFormat.NUMBER else: self._attr_code_format = alarm.CodeFormat.TEXT diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 7eb444b046a..7ab2e9ebf90 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime, timedelta -import functools import logging from typing import Any @@ -31,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.event as evt from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from . import subscription @@ -42,7 +41,7 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -77,21 +76,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT binary sensor through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttBinarySensor, + binary_sensor.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, binary_sensor.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT binary sensor.""" - async_add_entities([MqttBinarySensor(hass, config, config_entry, discovery_data)]) class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 47ac12386f7..f0d8037b60d 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -1,8 +1,6 @@ """Support for MQTT buttons.""" from __future__ import annotations -import functools - import voluptuous as vol from homeassistant.components import button @@ -12,7 +10,7 @@ from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( @@ -22,7 +20,11 @@ from .const import ( CONF_QOS, CONF_RETAIN, ) -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) from .models import MqttCommandTemplate from .util import valid_publish_topic @@ -50,21 +52,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT button through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttButton, + button.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, button.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT button.""" - async_add_entities([MqttButton(hass, config, config_entry, discovery_data)]) class MqttButton(MqttEntity, ButtonEntity): diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index c8402e501b0..954cddd20f7 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -2,7 +2,6 @@ from __future__ import annotations from base64 import b64decode -import functools import logging from typing import TYPE_CHECKING @@ -21,7 +20,11 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_QOS, CONF_TOPIC from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) from .models import ReceiveMessage from .util import valid_subscribe_topic @@ -61,21 +64,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT camera through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttCamera, + camera.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, camera.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Camera.""" - async_add_entities([MqttCamera(hass, config, config_entry, discovery_data)]) class MqttCamera(MqttEntity, Camera): diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 5acaefcdaeb..2e4d49b4cd9 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Iterable +from dataclasses import dataclass from functools import lru_cache from itertools import chain, groupby import logging @@ -12,7 +13,6 @@ import time from typing import TYPE_CHECKING, Any import uuid -import attr import certifi from homeassistant.config_entries import ConfigEntry @@ -218,15 +218,15 @@ def subscribe( return remove -@attr.s(slots=True, frozen=True) +@dataclass(frozen=True) class Subscription: """Class to hold data about an active subscription.""" - topic: str = attr.ib() - matcher: Any = attr.ib() - job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None] = attr.ib() - qos: int = attr.ib(default=0) - encoding: str | None = attr.ib(default="utf-8") + topic: str + matcher: Any + job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None] + qos: int = 0 + encoding: str | None = "utf-8" class MqttClientSetup: diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 77f28e1b5ca..dae768a1359 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable -import functools import logging from typing import Any @@ -47,7 +46,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter from . import subscription @@ -85,7 +84,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -399,22 +398,16 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up MQTT climate device through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + """Set up MQTT climate through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttClimate, + climate.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, climate.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT climate devices.""" - async_add_entities([MqttClimate(hass, config, config_entry, discovery_data)]) class MqttTemperatureControlEntity(MqttEntity, ABC): diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 79e977a90cd..71260dc0239 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -14,32 +14,6 @@ from homeassistant.const import ( ) from homeassistant.helpers import config_validation as cv -from . import ( - alarm_control_panel as alarm_control_panel_platform, - binary_sensor as binary_sensor_platform, - button as button_platform, - camera as camera_platform, - climate as climate_platform, - cover as cover_platform, - device_tracker as device_tracker_platform, - event as event_platform, - fan as fan_platform, - humidifier as humidifier_platform, - image as image_platform, - lawn_mower as lawn_mower_platform, - light as light_platform, - lock as lock_platform, - number as number_platform, - scene as scene_platform, - select as select_platform, - sensor as sensor_platform, - siren as siren_platform, - switch as switch_platform, - text as text_platform, - update as update_platform, - vacuum as vacuum_platform, - water_heater as water_heater_platform, -) from .const import ( CONF_BIRTH_MESSAGE, CONF_BROKER, @@ -56,102 +30,30 @@ DEFAULT_TLS_PROTOCOL = "auto" CONFIG_SCHEMA_BASE = vol.Schema( { - Platform.ALARM_CONTROL_PANEL.value: vol.All( - cv.ensure_list, - [alarm_control_panel_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] # noqa: E501 - ), - Platform.BINARY_SENSOR.value: vol.All( - cv.ensure_list, - [binary_sensor_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.BUTTON.value: vol.All( - cv.ensure_list, - [button_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.CAMERA.value: vol.All( - cv.ensure_list, - [camera_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.CLIMATE.value: vol.All( - cv.ensure_list, - [climate_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.COVER.value: vol.All( - cv.ensure_list, - [cover_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.DEVICE_TRACKER.value: vol.All( - cv.ensure_list, - [device_tracker_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.EVENT.value: vol.All( - cv.ensure_list, - [event_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.FAN.value: vol.All( - cv.ensure_list, - [fan_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.HUMIDIFIER.value: vol.All( - cv.ensure_list, - [humidifier_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.IMAGE.value: vol.All( - cv.ensure_list, - [image_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.LAWN_MOWER.value: vol.All( - cv.ensure_list, - [lawn_mower_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.LOCK.value: vol.All( - cv.ensure_list, - [lock_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.LIGHT.value: vol.All( - cv.ensure_list, - [light_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.NUMBER.value: vol.All( - cv.ensure_list, - [number_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.SCENE.value: vol.All( - cv.ensure_list, - [scene_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.SELECT.value: vol.All( - cv.ensure_list, - [select_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.SENSOR.value: vol.All( - cv.ensure_list, - [sensor_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.SIREN.value: vol.All( - cv.ensure_list, - [siren_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.SWITCH.value: vol.All( - cv.ensure_list, - [switch_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.TEXT.value: vol.All( - cv.ensure_list, - [text_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.UPDATE.value: vol.All( - cv.ensure_list, - [update_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.VACUUM.value: vol.All( - cv.ensure_list, - [vacuum_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), - Platform.WATER_HEATER.value: vol.All( - cv.ensure_list, - [water_heater_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.ALARM_CONTROL_PANEL.value: vol.All(cv.ensure_list, [dict]), + Platform.BINARY_SENSOR.value: vol.All(cv.ensure_list, [dict]), + Platform.BUTTON.value: vol.All(cv.ensure_list, [dict]), + Platform.CAMERA.value: vol.All(cv.ensure_list, [dict]), + Platform.CLIMATE.value: vol.All(cv.ensure_list, [dict]), + Platform.COVER.value: vol.All(cv.ensure_list, [dict]), + Platform.DEVICE_TRACKER.value: vol.All(cv.ensure_list, [dict]), + Platform.EVENT.value: vol.All(cv.ensure_list, [dict]), + Platform.FAN.value: vol.All(cv.ensure_list, [dict]), + Platform.HUMIDIFIER.value: vol.All(cv.ensure_list, [dict]), + Platform.IMAGE.value: vol.All(cv.ensure_list, [dict]), + Platform.LAWN_MOWER.value: vol.All(cv.ensure_list, [dict]), + Platform.LIGHT.value: vol.All(cv.ensure_list, [dict]), + Platform.LOCK.value: vol.All(cv.ensure_list, [dict]), + Platform.NUMBER.value: vol.All(cv.ensure_list, [dict]), + Platform.SCENE.value: vol.All(cv.ensure_list, [dict]), + Platform.SELECT.value: vol.All(cv.ensure_list, [dict]), + Platform.SENSOR.value: vol.All(cv.ensure_list, [dict]), + Platform.SIREN.value: vol.All(cv.ensure_list, [dict]), + Platform.SWITCH.value: vol.All(cv.ensure_list, [dict]), + Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), + Platform.UPDATE.value: vol.All(cv.ensure_list, [dict]), + Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), + Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]), } ) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 39c4090109c..c8da14e67e6 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -2,7 +2,6 @@ from __future__ import annotations from contextlib import suppress -import functools import logging from typing import Any @@ -31,7 +30,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import subscription @@ -48,7 +47,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -220,21 +219,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT cover through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttCover, + cover.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, cover.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Cover.""" - async_add_entities([MqttCover(hass, config, config_entry, discovery_data)]) class MqttCover(MqttEntity, CoverEntity): diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index 6b4b90586a7..41614a62f30 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -3,12 +3,11 @@ from __future__ import annotations from collections import deque from collections.abc import Callable +from dataclasses import dataclass import datetime as dt from functools import wraps from typing import TYPE_CHECKING, Any -import attr - from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import DiscoveryInfoType @@ -49,15 +48,15 @@ def log_messages( return _decorator -@attr.s(slots=True, frozen=True) +@dataclass class TimestampedPublishMessage: """MQTT Message.""" - topic: str = attr.ib() - payload: PublishPayloadType = attr.ib() - qos: int = attr.ib() - retain: bool = attr.ib() - timestamp: dt.datetime = attr.ib(default=None) + topic: str + payload: PublishPayloadType + qos: int + retain: bool + timestamp: dt.datetime def log_message( diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index a1bc2cdeb3e..c0e6f5750fb 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -7,29 +7,30 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import device_trigger from .config import MQTT_BASE_SCHEMA -from .mixins import async_setup_entry_helper +from .mixins import async_setup_non_entity_entry_helper AUTOMATION_TYPE_TRIGGER = "trigger" AUTOMATION_TYPES = [AUTOMATION_TYPE_TRIGGER] AUTOMATION_TYPES_SCHEMA = vol.In(AUTOMATION_TYPES) CONF_AUTOMATION_TYPE = "automation_type" -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( +DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend( {vol.Required(CONF_AUTOMATION_TYPE): AUTOMATION_TYPES_SCHEMA}, extra=vol.ALLOW_EXTRA, -).extend(MQTT_BASE_SCHEMA.schema) +) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Set up MQTT device automation dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_automation, hass, config_entry=config_entry) - await async_setup_entry_helper(hass, "device_automation", setup, PLATFORM_SCHEMA) + await async_setup_non_entity_entry_helper( + hass, "device_automation", setup, DISCOVERY_SCHEMA + ) async def _async_setup_automation( diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 2557a2afb5d..6e5aeb8f228 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -import functools +from typing import TYPE_CHECKING import voluptuous as vol @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_BASE_SCHEMA @@ -35,7 +35,7 @@ from .mixins import ( CONF_JSON_ATTRS_TOPIC, MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType @@ -84,22 +84,16 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up MQTT device_tracker through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + """Set up MQTT event through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttDeviceTracker, + device_tracker.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, device_tracker.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Device Tracker entity.""" - async_add_entities([MqttDeviceTracker(hass, config, config_entry, discovery_data)]) class MqttDeviceTracker(MqttEntity, TrackerEntity): @@ -137,7 +131,8 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): elif payload == self._config[CONF_PAYLOAD_RESET]: self._location_name = None else: - assert isinstance(msg.payload, str) + if TYPE_CHECKING: + assert isinstance(msg.payload, str) self._location_name = msg.payload state_topic: str | None = self._config.get(CONF_STATE_TOPIC) diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index c345655eea5..c9302bf65b1 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging from typing import Any @@ -19,7 +18,7 @@ from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLAT from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object from . import subscription @@ -35,7 +34,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -83,21 +82,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttEvent, + event.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, event.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up MQTT event.""" - async_add_entities([MqttEvent(hass, config, config_entry, discovery_data)]) class MqttEvent(MqttEntity, EventEntity): diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 0aad3a6afc0..02192676784 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging import math from typing import Any @@ -30,7 +29,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( int_states_in_range, percentage_to_ranged_value, @@ -53,7 +52,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -200,21 +199,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT fan through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttFan, + fan.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, fan.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT fan.""" - async_add_entities([MqttFan(hass, config, config_entry, discovery_data)]) class MqttFan(MqttEntity, FanEntity): diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 05929ee904a..77a74b15197 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging from typing import Any @@ -33,7 +32,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -55,7 +54,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -192,21 +191,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT humidifier through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttHumidifier, + humidifier.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, humidifier.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT humidifier.""" - async_add_entities([MqttHumidifier(hass, config, config_entry, discovery_data)]) class MqttHumidifier(MqttEntity, HumidifierEntity): diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index da526575a77..1f90f0fdb3d 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -4,7 +4,6 @@ from __future__ import annotations from base64 import b64decode import binascii from collections.abc import Callable -import functools import logging from typing import TYPE_CHECKING, Any @@ -27,7 +26,11 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_ENCODING, CONF_QOS from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_subscribe_topic @@ -79,21 +82,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT image through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttImage, + image.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, image.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Image.""" - async_add_entities([MqttImage(hass, config, config_entry, discovery_data)]) class MqttImage(MqttEntity, ImageEntity): diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 68c7eda16ea..924d34bf5c7 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable import contextlib -import functools import logging import voluptuous as vol @@ -20,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_BASE_SCHEMA @@ -35,7 +34,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -92,21 +91,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lawn mower through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttLawnMower, + lawn_mower.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, lawn_mower.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT lawn mower.""" - async_add_entities([MqttLawnMower(hass, config, config_entry, discovery_data)]) class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 2c70490ac5e..a5f3f0aca84 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -1,7 +1,6 @@ """Support for MQTT lights.""" from __future__ import annotations -import functools from typing import Any import voluptuous as vol @@ -10,24 +9,24 @@ from homeassistant.components import light from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from ..mixins import async_setup_entry_helper +from ..mixins import async_setup_entity_entry_helper from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( DISCOVERY_SCHEMA_BASIC, PLATFORM_SCHEMA_MODERN_BASIC, - async_setup_entity_basic, + MqttLight, ) from .schema_json import ( DISCOVERY_SCHEMA_JSON, PLATFORM_SCHEMA_MODERN_JSON, - async_setup_entity_json, + MqttLightJson, ) from .schema_template import ( DISCOVERY_SCHEMA_TEMPLATE, PLATFORM_SCHEMA_MODERN_TEMPLATE, - async_setup_entity_template, + MqttLightTemplate, ) @@ -70,25 +69,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lights through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry - ) - await async_setup_entry_helper(hass, light.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up a MQTT Light.""" - setup_entity = { - "basic": async_setup_entity_basic, - "json": async_setup_entity_json, - "template": async_setup_entity_template, - } - await setup_entity[config[CONF_SCHEMA]]( - hass, config, async_add_entities, config_entry, discovery_data + await async_setup_entity_entry_helper( + hass, + config_entry, + None, + light.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + {"basic": MqttLight, "json": MqttLightJson, "template": MqttLightTemplate}, ) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 65c05501658..2ca0a7e7e47 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -28,7 +28,6 @@ from homeassistant.components.light import ( LightEntityFeature, valid_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, @@ -36,11 +35,10 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util from .. import subscription @@ -228,17 +226,6 @@ DISCOVERY_SCHEMA_BASIC = vol.All( ) -async def async_setup_entity_basic( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a MQTT Light.""" - async_add_entities([MqttLight(hass, config, config_entry, discovery_data)]) - - class MqttLight(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT light.""" diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 462280b1516..6f70ff34051 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -32,7 +32,6 @@ from homeassistant.components.light import ( filter_supported_color_modes, valid_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, @@ -44,12 +43,11 @@ from homeassistant.const import ( CONF_XY, STATE_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util from homeassistant.util.json import json_loads_object @@ -166,17 +164,6 @@ PLATFORM_SCHEMA_MODERN_JSON = vol.All( ) -async def async_setup_entity_json( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a MQTT JSON Light.""" - async_add_entities([MqttLightJson(hass, config, config_entry, discovery_data)]) - - class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT JSON light.""" diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index a225ce43efa..e4900053fb3 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -20,7 +20,6 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, @@ -28,11 +27,10 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType import homeassistant.util.color as color_util from .. import subscription @@ -113,17 +111,6 @@ DISCOVERY_SCHEMA_TEMPLATE = vol.All( ) -async def async_setup_entity_template( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a MQTT Template light.""" - async_add_entities([MqttLightTemplate(hass, config, config_entry, discovery_data)]) - - class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT Template light.""" diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 9a0ce2077f3..26b6009426c 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import re from typing import Any @@ -20,7 +19,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import subscription from .config import MQTT_RW_SCHEMA @@ -37,7 +36,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -113,21 +112,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lock through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttLock, + lock.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, lock.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Lock platform.""" - async_add_entities([MqttLock(hass, config, config_entry, discovery_data)]) class MqttLock(MqttEntity, LockEntity): diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index a01691f0601..908e3c768b8 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -2,13 +2,14 @@ from __future__ import annotations from abc import ABC, abstractmethod -import asyncio from collections.abc import Callable, Coroutine +import functools from functools import partial, wraps import logging from typing import TYPE_CHECKING, Any, Protocol, cast, final import voluptuous as vol +import yaml from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,7 +29,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -53,6 +54,7 @@ from homeassistant.helpers.event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ( UNDEFINED, ConfigType, @@ -79,6 +81,7 @@ from .const import ( CONF_OBJECT_ID, CONF_ORIGIN, CONF_QOS, + CONF_SCHEMA, CONF_SUGGESTED_AREA, CONF_SW_VERSION, CONF_TOPIC, @@ -270,71 +273,176 @@ def async_handle_schema_error( ) -async def async_setup_entry_helper( +async def _async_discover( + hass: HomeAssistant, + domain: str, + setup: partial[CALLBACK_TYPE] | None, + async_setup: partial[Coroutine[Any, Any, None]] | None, + discovery_payload: MQTTDiscoveryPayload, +) -> None: + """Discover and add an MQTT entity, automation or tag. + + setup is to be run in the event loop when there is nothing to be awaited. + """ + if not mqtt_config_entry_enabled(hass): + _LOGGER.warning( + ( + "MQTT integration is disabled, skipping setup of discovered item " + "MQTT %s, payload %s" + ), + domain, + discovery_payload, + ) + return + discovery_data = discovery_payload.discovery_data + try: + if setup is not None: + setup(discovery_payload) + elif async_setup is not None: + await async_setup(discovery_payload) + except vol.Invalid as err: + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + clear_discovery_hash(hass, discovery_hash) + async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None) + async_handle_schema_error(discovery_payload, err) + except Exception: + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + clear_discovery_hash(hass, discovery_hash) + async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None) + raise + + +async def async_setup_non_entity_entry_helper( hass: HomeAssistant, domain: str, async_setup: partial[Coroutine[Any, Any, None]], discovery_schema: vol.Schema, ) -> None: - """Set up entity, automation or tag creation dynamically through MQTT discovery.""" + """Set up automation or tag creation dynamically through MQTT discovery.""" mqtt_data = get_mqtt_data(hass) - async def async_discover(discovery_payload: MQTTDiscoveryPayload) -> None: - """Discover and add an MQTT entity, automation or tag.""" - if not mqtt_config_entry_enabled(hass): - _LOGGER.warning( - ( - "MQTT integration is disabled, skipping setup of discovered item " - "MQTT %s, payload %s" - ), - domain, - discovery_payload, - ) - return - discovery_data = discovery_payload.discovery_data - try: - config: DiscoveryInfoType = discovery_schema(discovery_payload) - await async_setup(config, discovery_data=discovery_data) - except vol.Invalid as err: - discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] - clear_discovery_hash(hass, discovery_hash) - async_dispatcher_send( - hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None - ) - async_handle_schema_error(discovery_payload, err) - except Exception: - discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] - clear_discovery_hash(hass, discovery_hash) - async_dispatcher_send( - hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None - ) - raise + async def async_setup_from_discovery( + discovery_payload: MQTTDiscoveryPayload, + ) -> None: + """Set up an MQTT entity, automation or tag from discovery.""" + config: DiscoveryInfoType = discovery_schema(discovery_payload) + await async_setup(config, discovery_data=discovery_payload.discovery_data) mqtt_data.reload_dispatchers.append( async_dispatcher_connect( - hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), async_discover + hass, + MQTT_DISCOVERY_NEW.format(domain, "mqtt"), + functools.partial( + _async_discover, hass, domain, None, async_setup_from_discovery + ), ) ) - async def _async_setup_entities() -> None: + +async def async_setup_entity_entry_helper( + hass: HomeAssistant, + entry: ConfigEntry, + entity_class: type[MqttEntity] | None, + domain: str, + async_add_entities: AddEntitiesCallback, + discovery_schema: vol.Schema, + platform_schema_modern: vol.Schema, + schema_class_mapping: dict[str, type[MqttEntity]] | None = None, +) -> None: + """Set up entity creation dynamically through MQTT discovery.""" + mqtt_data = get_mqtt_data(hass) + + @callback + def async_setup_from_discovery( + discovery_payload: MQTTDiscoveryPayload, + ) -> None: + """Set up an MQTT entity from discovery.""" + nonlocal entity_class + config: DiscoveryInfoType = discovery_schema(discovery_payload) + if schema_class_mapping is not None: + entity_class = schema_class_mapping[config[CONF_SCHEMA]] + if TYPE_CHECKING: + assert entity_class is not None + async_add_entities( + [entity_class(hass, config, entry, discovery_payload.discovery_data)] + ) + + mqtt_data.reload_dispatchers.append( + async_dispatcher_connect( + hass, + MQTT_DISCOVERY_NEW.format(domain, "mqtt"), + functools.partial( + _async_discover, hass, domain, async_setup_from_discovery, None + ), + ) + ) + + @callback + def _async_setup_entities() -> None: """Set up MQTT items from configuration.yaml.""" + nonlocal entity_class mqtt_data = get_mqtt_data(hass) if not (config_yaml := mqtt_data.config): return - setups: list[Coroutine[Any, Any, None]] = [ - async_setup(config) + yaml_configs: list[ConfigType] = [ + config for config_item in config_yaml for config_domain, configs in config_item.items() for config in configs if config_domain == domain ] - if not setups: - return - await asyncio.gather(*setups) + entities: list[Entity] = [] + for yaml_config in yaml_configs: + try: + config = platform_schema_modern(yaml_config) + if schema_class_mapping is not None: + entity_class = schema_class_mapping[config[CONF_SCHEMA]] + if TYPE_CHECKING: + assert entity_class is not None + entities.append(entity_class(hass, config, entry, None)) + except vol.Invalid as ex: + error = str(ex) + config_file = getattr(yaml_config, "__config_file__", "?") + line = getattr(yaml_config, "__line__", "?") + issue_id = hex(hash(frozenset(yaml_config.items()))) + yaml_config_str = yaml.dump(dict(yaml_config)) + learn_more_url = ( + f"https://www.home-assistant.io/integrations/{domain}.mqtt/" + ) + async_create_issue( + hass, + DOMAIN, + issue_id, + issue_domain=domain, + is_fixable=False, + severity=IssueSeverity.ERROR, + learn_more_url=learn_more_url, + translation_placeholders={ + "domain": domain, + "config_file": config_file, + "line": line, + "config": yaml_config_str, + "error": error, + }, + translation_key="invalid_platform_config", + ) + _LOGGER.error( + "%s for manual configured MQTT %s item, in %s, line %s Got %s", + error, + domain, + config_file, + line, + yaml_config, + ) + async_add_entities(entities) + + # When reloading we check manual configured items against the schema + # before reloading + mqtt_data.reload_schema[domain] = platform_schema_modern # discover manual configured MQTT items mqtt_data.reload_handlers[domain] = _async_setup_entities - await _async_setup_entities() + _async_setup_entities() def init_entity_id_from_config( diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 8c599469ff2..2da2527ad7b 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -11,7 +11,7 @@ from enum import StrEnum import logging from typing import TYPE_CHECKING, Any, TypedDict -import attr +import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -44,26 +44,26 @@ ATTR_THIS = "this" PublishPayloadType = str | bytes | int | float | None -@attr.s(slots=True, frozen=True) +@dataclass class PublishMessage: - """MQTT Message.""" + """MQTT Message for publishing.""" - topic: str = attr.ib() - payload: PublishPayloadType = attr.ib() - qos: int = attr.ib() - retain: bool = attr.ib() + topic: str + payload: PublishPayloadType + qos: int + retain: bool -@attr.s(slots=True, frozen=True) +@dataclass class ReceiveMessage: - """MQTT Message.""" + """MQTT Message received.""" - topic: str = attr.ib() - payload: ReceivePayloadType = attr.ib() - qos: int = attr.ib() - retain: bool = attr.ib() - subscribed_topic: str = attr.ib(default=None) - timestamp: dt.datetime = attr.ib(default=None) + topic: str + payload: ReceivePayloadType + qos: int + retain: bool + subscribed_topic: str + timestamp: dt.datetime AsyncMessageCallbackType = Callable[[ReceiveMessage], Coroutine[Any, Any, None]] @@ -342,9 +342,8 @@ class MqttData: issues: dict[str, set[str]] = field(default_factory=dict) last_discovery: float = 0.0 reload_dispatchers: list[CALLBACK_TYPE] = field(default_factory=list) - reload_handlers: dict[str, Callable[[], Coroutine[Any, Any, None]]] = field( - default_factory=dict - ) + reload_handlers: dict[str, CALLBACK_TYPE] = field(default_factory=dict) + reload_schema: dict[str, vol.Schema] = field(default_factory=dict) state_write_requests: EntityTopicState = field(default_factory=EntityTopicState) subscriptions_to_restore: list[Subscription] = field(default_factory=list) tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 231da95ffb0..83eb047519f 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging import voluptuous as vol @@ -28,7 +27,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -45,7 +44,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -118,21 +117,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT number through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttNumber, + number.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, number.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT number.""" - async_add_entities([MqttNumber(hass, config, config_entry, discovery_data)]) class MqttNumber(MqttEntity, RestoreNumber): diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 9e7c280cbc0..de75f470228 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -1,7 +1,6 @@ """Support for MQTT scenes.""" from __future__ import annotations -import functools from typing import Any import voluptuous as vol @@ -13,11 +12,15 @@ from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) from .util import valid_publish_topic DEFAULT_NAME = "MQTT Scene" @@ -43,21 +46,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT scene through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttScene, + scene.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, scene.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT scene.""" - async_add_entities([MqttScene(hass, config, config_entry, discovery_data)]) class MqttScene( diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 03cd529fdd0..5d9bc989c25 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging import voluptuous as vol @@ -15,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -31,7 +30,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -73,21 +72,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT select through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttSelect, + select.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, select.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT select.""" - async_add_entities([MqttSelect(hass, config, config_entry, discovery_data)]) class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 0f73b93f1de..93151c51542 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta -import functools import logging from typing import Any @@ -33,7 +32,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from . import subscription @@ -44,7 +43,7 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -106,21 +105,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT sensor through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttSensor, + sensor.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, sensor.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up MQTT sensor.""" - async_add_entities([MqttSensor(hass, config, config_entry, discovery_data)]) class MqttSensor(MqttEntity, RestoreSensor): diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index 4960cf9fb82..5102d481143 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -15,7 +15,7 @@ publish: advanced: true example: "{{ states('sensor.temperature') }}" selector: - object: + template: qos: advanced: true default: 0 diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 7978776a089..cb2ecbafa55 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging from typing import Any, cast @@ -32,7 +31,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object from . import subscription @@ -52,7 +51,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -122,21 +121,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT siren through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttSiren, + siren.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, siren.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT siren.""" - async_add_entities([MqttSiren(hass, config, config_entry, discovery_data)]) class MqttSiren(MqttEntity, SirenEntity): diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index b28f16cb404..6197e580b1d 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -19,6 +19,10 @@ "deprecated_climate_aux_property": { "title": "MQTT entities with auxiliary heat support found", "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." + }, + "invalid_platform_config": { + "title": "Invalid config found for mqtt {domain} item", + "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." } }, "config": { @@ -177,7 +181,7 @@ }, "qos": { "name": "QoS", - "description": "Quality of Service to use. O. At most once. 1: At least once. 2: Exactly once." + "description": "Quality of Service to use. 0: At most once. 1: At least once. 2: Exactly once." }, "retain": { "name": "Retain", diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index d4e8f2609d9..c45e6dd77ab 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools from typing import Any import voluptuous as vol @@ -24,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -40,7 +39,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -72,21 +71,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT switch through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttSwitch, + switch.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, switch.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT switch.""" - async_add_entities([MqttSwitch(hass, config, config_entry, discovery_data)]) class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 848950169d8..80a717b1f37 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components import tag from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, CONF_PLATFORM, CONF_VALUE_TEMPLATE +from homeassistant.const import CONF_DEVICE, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -21,7 +21,7 @@ from .mixins import ( MQTT_ENTITY_DEVICE_INFO_SCHEMA, MqttDiscoveryDeviceUpdate, async_handle_schema_error, - async_setup_entry_helper, + async_setup_non_entity_entry_helper, send_discovery_done, update_device, ) @@ -33,10 +33,9 @@ LOG_NAME = "Tag" TAG = "tag" -PLATFORM_SCHEMA = MQTT_BASE_SCHEMA.extend( +DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, - vol.Optional(CONF_PLATFORM): "mqtt", vol.Required(CONF_TOPIC): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }, @@ -48,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N """Set up MQTT tag scanner dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_tag, hass, config_entry=config_entry) - await async_setup_entry_helper(hass, TAG, setup, PLATFORM_SCHEMA) + await async_setup_non_entity_entry_helper(hass, TAG, setup, DISCOVERY_SCHEMA) async def _async_setup_tag( @@ -121,7 +120,7 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdate): """Handle MQTT tag discovery updates.""" # Update tag scanner try: - config: DiscoveryInfoType = PLATFORM_SCHEMA(discovery_data) + config: DiscoveryInfoType = DISCOVERY_SCHEMA(discovery_data) except vol.Invalid as err: async_handle_schema_error(discovery_data, err) return diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 630951f171e..f6aeac3be7c 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging import re from typing import Any @@ -22,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -38,7 +37,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -108,21 +107,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT text through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttTextEntity, + text.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, text.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT text.""" - async_add_entities([MqttTextEntity(hass, config, config_entry, discovery_data)]) class MqttTextEntity(MqttEntity, TextEntity): diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 45cca7279f9..45424995224 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -1,7 +1,6 @@ """Configure update platform in a device through MQTT topic.""" from __future__ import annotations -import functools import logging from typing import Any, TypedDict, cast @@ -19,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import subscription @@ -36,7 +35,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage @@ -91,22 +90,16 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up MQTT update through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + """Set up MQTT update entity through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttUpdate, + update.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, update.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT update.""" - async_add_entities([MqttUpdate(hass, config, config_entry, discovery_data)]) class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 3a2586bdfd7..fabbb9868df 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -5,30 +5,29 @@ from __future__ import annotations -import functools import logging import voluptuous as vol from homeassistant.components import vacuum from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, async_get_hass, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from ..const import DOMAIN -from ..mixins import async_setup_entry_helper +from ..mixins import async_setup_entity_entry_helper from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE from .schema_legacy import ( DISCOVERY_SCHEMA_LEGACY, PLATFORM_SCHEMA_LEGACY_MODERN, - async_setup_entity_legacy, + MqttVacuum, ) from .schema_state import ( DISCOVERY_SCHEMA_STATE, PLATFORM_SCHEMA_STATE_MODERN, - async_setup_entity_state, + MqttStateVacuum, ) _LOGGER = logging.getLogger(__name__) @@ -39,13 +38,13 @@ MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 # and will be removed with HA Core 2024.2.0 def warn_for_deprecation_legacy_schema( - hass: HomeAssistant, config: ConfigType, discovery_data: DiscoveryInfoType | None + hass: HomeAssistant, config: ConfigType, discovery: bool ) -> None: """Warn for deprecation of legacy schema.""" if config[CONF_SCHEMA] == STATE: return - key_suffix = "yaml" if discovery_data is None else "discovery" + key_suffix = "discovery" if discovery else "yaml" translation_key = f"deprecation_mqtt_legacy_vacuum_{key_suffix}" async_create_issue( hass, @@ -63,6 +62,7 @@ def warn_for_deprecation_legacy_schema( ) +@callback def validate_mqtt_vacuum_discovery(config_value: ConfigType) -> ConfigType: """Validate MQTT vacuum schema.""" @@ -71,9 +71,12 @@ def validate_mqtt_vacuum_discovery(config_value: ConfigType) -> ConfigType: schemas = {LEGACY: DISCOVERY_SCHEMA_LEGACY, STATE: DISCOVERY_SCHEMA_STATE} config: ConfigType = schemas[config_value[CONF_SCHEMA]](config_value) + hass = async_get_hass() + warn_for_deprecation_legacy_schema(hass, config, True) return config +@callback def validate_mqtt_vacuum_modern(config_value: ConfigType) -> ConfigType: """Validate MQTT vacuum modern schema.""" @@ -85,6 +88,10 @@ def validate_mqtt_vacuum_modern(config_value: ConfigType) -> ConfigType: STATE: PLATFORM_SCHEMA_STATE_MODERN, } config: ConfigType = schemas[config_value[CONF_SCHEMA]](config_value) + # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 + # and will be removed with HA Core 2024.2.0 + hass = async_get_hass() + warn_for_deprecation_legacy_schema(hass, config, False) return config @@ -103,28 +110,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT vacuum through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry - ) - await async_setup_entry_helper(hass, vacuum.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT vacuum.""" - - # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 - # and will be removed with HA Core 2024.2.0 - warn_for_deprecation_legacy_schema(hass, config, discovery_data) - setup_entity = { - LEGACY: async_setup_entity_legacy, - STATE: async_setup_entity_state, - } - await setup_entity[config[CONF_SCHEMA]]( - hass, config, async_add_entities, config_entry, discovery_data + await async_setup_entity_entry_helper( + hass, + config_entry, + None, + vacuum.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + {"legacy": MqttVacuum, "state": MqttStateVacuum}, ) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index aee71cc6690..ab13de59ede 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -17,14 +17,12 @@ from homeassistant.components.vacuum import ( VacuumEntity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.json import json_dumps -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .. import subscription from ..config import MQTT_BASE_SCHEMA @@ -201,17 +199,6 @@ _COMMANDS = { } -async def async_setup_entity_legacy( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a MQTT Vacuum Legacy.""" - async_add_entities([MqttVacuum(hass, config, config_entry, discovery_data)]) - - class MqttVacuum(MqttEntity, VacuumEntity): """Representation of a MQTT-controlled legacy vacuum.""" diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 425202adea2..a51429f0c05 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -23,7 +23,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import json_loads_object @@ -156,17 +155,6 @@ PLATFORM_SCHEMA_STATE_MODERN = ( DISCOVERY_SCHEMA_STATE = PLATFORM_SCHEMA_STATE_MODERN.extend({}, extra=vol.REMOVE_EXTRA) -async def async_setup_entity_state( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a State MQTT Vacuum.""" - async_add_entities([MqttStateVacuum(hass, config, config_entry, discovery_data)]) - - class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Representation of a MQTT-controlled state vacuum.""" diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 9a9326d6d07..0ccd2dbc47d 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -1,7 +1,6 @@ """Support for MQTT water heater devices.""" from __future__ import annotations -import functools import logging from typing import Any @@ -38,7 +37,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter from .climate import MqttTemperatureControlEntity @@ -67,7 +66,7 @@ from .const import ( from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, - async_setup_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -170,21 +169,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT water heater device through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttWaterHeater, + water_heater.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, water_heater.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT water heater devices.""" - async_add_entities([MqttWaterHeater(hass, config, config_entry, discovery_data)]) class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 4eb3a3f5171..cb0e840604e 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -47,7 +47,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } -).extend(mqtt.config.MQTT_RO_SCHEMA.schema) +).extend(mqtt.MQTT_RO_SCHEMA.schema) @lru_cache(maxsize=256) diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index e9d4502242e..d532135304a 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -144,7 +144,7 @@ class MySensorsHVAC(mysensors.device.MySensorsChildEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return current operation ie. heat, cool, idle.""" - return self._values.get(self.value_type, HVACMode.HEAT) + return self._values.get(self.value_type, HVACMode.HEAT) # type: ignore[no-any-return] @property def fan_mode(self) -> str | None: diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index b3c3d11f279..8011bfcb155 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -131,6 +131,12 @@ class MySensorsConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, str] | None = None ) -> FlowResult: """Create a config entry from frontend user input.""" + return await self.async_step_select_gateway_type() + + async def async_step_select_gateway_type( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Show the select gateway type menu.""" return self.async_show_menu( step_id="select_gateway_type", menu_options=["gw_serial", "gw_tcp", "gw_mqtt"], diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py index 3166c05db19..3b033e3338c 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .models import MyStromData -PLATFORMS_SWITCH = [Platform.SWITCH] +PLATFORMS_PLUGS = [Platform.SENSOR, Platform.SWITCH] PLATFORMS_BULB = [Platform.LIGHT] _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,6 @@ def _get_mystrom_switch(host: str) -> MyStromSwitch: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up myStrom from a config entry.""" host = entry.data[CONF_HOST] - device = None try: info = await pymystrom.get_device_info(host) except MyStromConnectionError as err: @@ -55,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_type = info["type"] if device_type in [101, 106, 107, 120]: device = _get_mystrom_switch(host) - platforms = PLATFORMS_SWITCH + platforms = PLATFORMS_PLUGS await _async_get_device_state(device, info["ip"]) elif device_type in [102, 105]: mac = info["mac"] @@ -87,7 +86,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_type = hass.data[DOMAIN][entry.entry_id].info["type"] platforms = [] if device_type in [101, 106, 107, 120]: - platforms.extend(PLATFORMS_SWITCH) + platforms.extend(PLATFORMS_PLUGS) elif device_type in [102, 105]: platforms.extend(PLATFORMS_BULB) if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py new file mode 100644 index 00000000000..606a6275acf --- /dev/null +++ b/homeassistant/components/mystrom/sensor.py @@ -0,0 +1,91 @@ +"""Support for myStrom sensors of switches/plugs.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pymystrom.switch import MyStromSwitch + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPower, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, MANUFACTURER + + +@dataclass +class MyStromSwitchSensorEntityDescription(SensorEntityDescription): + """Class describing mystrom switch sensor entities.""" + + value_fn: Callable[[MyStromSwitch], float | None] = lambda _: None + + +SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = ( + MyStromSwitchSensorEntityDescription( + key="consumption", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda device: device.consumption, + ), + MyStromSwitchSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda device: device.temperature, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the myStrom entities.""" + device: MyStromSwitch = hass.data[DOMAIN][entry.entry_id].device + sensors = [] + + for description in SENSOR_TYPES: + if description.value_fn(device) is not None: + sensors.append(MyStromSwitchSensor(device, entry.title, description)) + + async_add_entities(sensors) + + +class MyStromSwitchSensor(SensorEntity): + """Representation of the consumption or temperature of a myStrom switch/plug.""" + + entity_description: MyStromSwitchSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + device: MyStromSwitch, + name: str, + description: MyStromSwitchSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.device = device + self.entity_description = description + + self._attr_unique_id = f"{device.mac}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.mac)}, + name=name, + manufacturer=MANUFACTURER, + sw_version=device.firmware, + ) + + @property + def native_value(self) -> float | None: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 32f7329a0a2..be571460b4a 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], "quality_scale": "platinum", - "requirements": ["nettigo-air-monitor==2.1.0"], + "requirements": ["nettigo-air-monitor==2.2.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index bf24fc4a4e9..89244642207 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==3.0.2"] + "requirements": ["google-nest-sdm==3.0.3"] } diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index f575f227753..ddd2fc61ed7 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -1,10 +1,10 @@ """The Netatmo integration.""" from __future__ import annotations -from datetime import datetime from http import HTTPStatus import logging import secrets +from typing import Any import aiohttp import pyatmo @@ -26,16 +26,9 @@ from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID, - EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - CoreState, - Event, - HomeAssistant, - ServiceCall, -) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, @@ -45,6 +38,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from . import api @@ -185,7 +179,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await data_handler.async_setup() async def unregister_webhook( - call_or_event_or_dt: ServiceCall | Event | datetime | None, + _: Any, ) -> None: if CONF_WEBHOOK_ID not in entry.data: return @@ -204,7 +198,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) async def register_webhook( - call_or_event_or_dt: ServiceCall | Event | datetime | None, + _: Any, ) -> None: if CONF_WEBHOOK_ID not in entry.data: data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} @@ -248,22 +242,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED: await unregister_webhook(None) - async_call_later(hass, 30, register_webhook) + entry.async_on_unload(async_call_later(hass, 30, register_webhook)) if cloud.async_active_subscription(hass): if cloud.async_is_connected(hass): await register_webhook(None) - cloud.async_listen_connection_change(hass, manage_cloudhook) - - elif hass.state == CoreState.running: - await register_webhook(None) + entry.async_on_unload( + cloud.async_listen_connection_change(hass, manage_cloudhook) + ) else: - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, register_webhook) + entry.async_on_unload(async_at_started(hass, register_webhook)) hass.services.async_register(DOMAIN, "register_webhook", register_webhook) hass.services.async_register(DOMAIN, "unregister_webhook", unregister_webhook) - entry.add_update_listener(async_config_entry_updated) + entry.async_on_unload(entry.add_update_listener(async_config_entry_updated)) return True diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 9f34df9b39c..f4715015844 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -8,6 +8,7 @@ from pyatmo.modules import NATherm1 import voluptuous as vol from homeassistant.components.climate import ( + ATTR_PRESET_MODE, DEFAULT_MIN_TEMP, PRESET_AWAY, PRESET_BOOST, @@ -30,8 +31,10 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from .const import ( + ATTR_END_DATETIME, ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, @@ -43,6 +46,7 @@ from .const import ( EVENT_TYPE_SET_POINT, EVENT_TYPE_THERM_MODE, NETATMO_CREATE_CLIMATE, + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, SERVICE_SET_SCHEDULE, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoRoom @@ -59,6 +63,8 @@ SUPPORT_FLAGS = ( ) SUPPORT_PRESET = [PRESET_AWAY, PRESET_BOOST, PRESET_FROST_GUARD, PRESET_SCHEDULE] +THERM_MODES = (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY) + STATE_NETATMO_SCHEDULE = "schedule" STATE_NETATMO_HG = "hg" STATE_NETATMO_MAX = "max" @@ -124,6 +130,14 @@ async def async_setup_entry( {vol.Required(ATTR_SCHEDULE_NAME): cv.string}, "_async_service_set_schedule", ) + platform.async_register_entity_service( + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, + { + vol.Required(ATTR_PRESET_MODE): vol.In(THERM_MODES), + vol.Required(ATTR_END_DATETIME): cv.datetime, + }, + "_async_service_set_preset_mode_with_end_datetime", + ) class NetatmoThermostat(NetatmoBase, ClimateEntity): @@ -314,7 +328,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): await self._room.async_therm_set(STATE_NETATMO_HOME) elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX): await self._room.async_therm_set(PRESET_MAP_NETATMO[preset_mode]) - elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY): + elif preset_mode in THERM_MODES: await self._room.home.async_set_thermmode(PRESET_MAP_NETATMO[preset_mode]) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) @@ -410,6 +424,23 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): schedule_id, ) + async def _async_service_set_preset_mode_with_end_datetime( + self, **kwargs: Any + ) -> None: + preset_mode = kwargs[ATTR_PRESET_MODE] + end_datetime = kwargs[ATTR_END_DATETIME] + end_timestamp = int(dt_util.as_timestamp(end_datetime)) + + await self._room.home.async_set_thermmode( + mode=PRESET_MAP_NETATMO[preset_mode], end_time=end_timestamp + ) + _LOGGER.debug( + "Setting %s preset to %s with optional end datetime to %s", + self._room.home.entity_id, + preset_mode, + end_timestamp, + ) + @property def device_info(self) -> DeviceInfo: """Return the device info for the thermostat.""" diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 3e489fe8ea5..9e7ac33c8b6 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -69,6 +69,7 @@ DEFAULT_PERSON = "unknown" DEFAULT_WEBHOOKS = False ATTR_CAMERA_LIGHT_MODE = "camera_light_mode" +ATTR_END_DATETIME = "end_datetime" ATTR_EVENT_TYPE = "event_type" ATTR_FACE_URL = "face_url" ATTR_HEATING_POWER_REQUEST = "heating_power_request" @@ -86,6 +87,7 @@ SERVICE_SET_CAMERA_LIGHT = "set_camera_light" SERVICE_SET_PERSON_AWAY = "set_person_away" SERVICE_SET_PERSONS_HOME = "set_persons_home" SERVICE_SET_SCHEDULE = "set_schedule" +SERVICE_SET_PRESET_MODE_WITH_END_DATETIME = "set_preset_mode_with_end_datetime" # Climate events EVENT_TYPE_CANCEL_SET_POINT = "cancel_set_point" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index c80bd351ccf..8fa8ab2073d 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -10,6 +10,7 @@ import logging from time import time from typing import Any +import aiohttp import pyatmo from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory @@ -211,6 +212,10 @@ class NetatmoDataHandler: _LOGGER.debug(err) return + except aiohttp.ClientConnectorError as err: + _LOGGER.debug(err) + return + for update_callback in self.publisher[signal_name].subscriptions: if update_callback: update_callback() diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 949c7336ea4..f286e53772c 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -87,7 +87,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="temperature", name="Temperature", netatmo_name="temperature", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, @@ -104,7 +103,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="CO2", netatmo_name="co2", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - entity_registry_enabled_default=True, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CO2, ), @@ -112,7 +110,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="pressure", name="Pressure", netatmo_name="pressure", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -128,7 +125,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="noise", name="Noise", netatmo_name="noise", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, device_class=SensorDeviceClass.SOUND_PRESSURE, state_class=SensorStateClass.MEASUREMENT, @@ -137,7 +133,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="humidity", name="Humidity", netatmo_name="humidity", - entity_registry_enabled_default=True, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, @@ -146,7 +141,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="rain", name="Rain", netatmo_name="rain", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, @@ -164,7 +158,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="sum_rain_24", name="Rain today", netatmo_name="sum_rain_24", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, @@ -173,7 +166,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="battery_percent", name="Battery Percent", netatmo_name="battery", - entity_registry_enabled_default=True, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -183,7 +175,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="windangle", name="Direction", netatmo_name="wind_direction", - entity_registry_enabled_default=True, icon="mdi:compass-outline", ), NetatmoSensorEntityDescription( @@ -199,7 +190,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="windstrength", name="Wind Strength", netatmo_name="wind_strength", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -257,14 +247,12 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="health_idx", name="Health", netatmo_name="health_idx", - entity_registry_enabled_default=True, icon="mdi:cloud", ), NetatmoSensorEntityDescription( key="power", name="Power", netatmo_name="power", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index 726d6867d2d..228f84f175d 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -26,6 +26,26 @@ set_schedule: selector: text: +set_preset_mode_with_end_datetime: + target: + entity: + integration: netatmo + domain: climate + fields: + preset_mode: + required: true + example: "away" + selector: + select: + options: + - "away" + - "Frost Guard" + end_datetime: + required: true + example: '"2019-04-20 05:04:20"' + selector: + datetime: + set_persons_home: target: entity: diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index e9125f33016..593320827fd 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -115,6 +115,20 @@ "unregister_webhook": { "name": "Unregister webhook", "description": "Unregisters the webhook from the Netatmo backend." + }, + "set_preset_mode_with_end_datetime": { + "name": "Set preset mode with end datetime", + "description": "Sets the preset mode for a Netatmo climate device. The preset mode must match a preset mode configured at Netatmo.", + "fields": { + "preset_mode": { + "name": "Preset mode", + "description": "Climate preset mode such as Schedule, Away or Frost Guard." + }, + "end_datetime": { + "name": "End datetime", + "description": "Datetime for until when the preset will be active." + } + } } } } diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index b582f82b929..e1f4dcc2840 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -4,15 +4,41 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .coordinator import NextBusDataUpdateCoordinator + PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up platforms for NextBus.""" + entry_agency = entry.data[CONF_AGENCY] + + coordinator: NextBusDataUpdateCoordinator = hass.data.setdefault(DOMAIN, {}).get( + entry_agency + ) + if coordinator is None: + coordinator = NextBusDataUpdateCoordinator(hass, entry_agency) + hass.data[DOMAIN][entry_agency] = coordinator + + coordinator.add_stop_route(entry.data[CONF_STOP], entry.data[CONF_ROUTE]) + + 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) + if await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry_agency = entry.data.get(CONF_AGENCY) + coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][entry_agency] + coordinator.remove_stop_route(entry.data[CONF_STOP], entry.data[CONF_ROUTE]) + if not coordinator.has_routes(): + hass.data[DOMAIN].pop(entry_agency) + + return True + + return False diff --git a/homeassistant/components/nextbus/coordinator.py b/homeassistant/components/nextbus/coordinator.py new file mode 100644 index 00000000000..f130e40ef05 --- /dev/null +++ b/homeassistant/components/nextbus/coordinator.py @@ -0,0 +1,78 @@ +"""NextBus data update coordinator.""" +from datetime import timedelta +import logging +from typing import Any, cast + +from py_nextbus import NextBusClient +from py_nextbus.client import NextBusFormatError, NextBusHTTPError, RouteStop + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .util import listify + +_LOGGER = logging.getLogger(__name__) + + +class NextBusDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching NextBus data.""" + + def __init__(self, hass: HomeAssistant, agency: str) -> None: + """Initialize a global coordinator for fetching data for a given agency.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.client = NextBusClient(output_format="json", agency=agency) + self._agency = agency + self._stop_routes: set[RouteStop] = set() + self._predictions: dict[RouteStop, dict[str, Any]] = {} + + def add_stop_route(self, stop_tag: str, route_tag: str) -> None: + """Tell coordinator to start tracking a given stop and route.""" + self._stop_routes.add(RouteStop(route_tag, stop_tag)) + + def remove_stop_route(self, stop_tag: str, route_tag: str) -> None: + """Tell coordinator to stop tracking a given stop and route.""" + self._stop_routes.remove(RouteStop(route_tag, stop_tag)) + + def get_prediction_data( + self, stop_tag: str, route_tag: str + ) -> dict[str, Any] | None: + """Get prediction result for a given stop and route.""" + return self._predictions.get(RouteStop(route_tag, stop_tag)) + + def _calc_predictions(self, data: dict[str, Any]) -> None: + self._predictions = { + RouteStop(prediction["routeTag"], prediction["stopTag"]): prediction + for prediction in listify(data.get("predictions", [])) + } + + def get_attribution(self) -> str | None: + """Get attribution from api results.""" + return self.data.get("copyright") + + def has_routes(self) -> bool: + """Check if this coordinator is tracking any routes.""" + return len(self._stop_routes) > 0 + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from NextBus.""" + self.logger.debug("Updating data from API. Routes: %s", str(self._stop_routes)) + + def _update_data() -> dict: + """Fetch data from NextBus.""" + self.logger.debug("Updating data from API (executor)") + try: + data = self.client.get_predictions_for_multi_stops(self._stop_routes) + # Casting here because we expect dict and not a str due to the input format selected being JSON + data = cast(dict[str, Any], data) + self._calc_predictions(data) + return data + except (NextBusHTTPError, NextBusFormatError) as ex: + raise UpdateFailed("Failed updating nextbus data", ex) from ex + + return await self.hass.async_add_executor_job(_update_data) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 15eb9b4e245..9d1490a4ae6 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==0.1.5"] + "requirements": ["py-nextbusnext==1.0.0"] } diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 1582ec25ffe..6ef647f98ad 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -3,8 +3,8 @@ from __future__ import annotations from itertools import chain import logging +from typing import cast -from py_nextbus import NextBusClient import voluptuous as vol from homeassistant.components.sensor import ( @@ -14,14 +14,16 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .coordinator import NextBusDataUpdateCoordinator from .util import listify, maybe_first _LOGGER = logging.getLogger(__name__) @@ -70,23 +72,28 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Load values from configuration and initialize the platform.""" - client = NextBusClient(output_format="json") - _LOGGER.debug(config.data) + entry_agency = config.data[CONF_AGENCY] - sensor = NextBusDepartureSensor( - client, - config.unique_id, - config.data[CONF_AGENCY], - config.data[CONF_ROUTE], - config.data[CONF_STOP], - config.data.get(CONF_NAME) or config.title, + coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN].get(entry_agency) + + async_add_entities( + ( + NextBusDepartureSensor( + coordinator, + cast(str, config.unique_id), + config.data[CONF_AGENCY], + config.data[CONF_ROUTE], + config.data[CONF_STOP], + config.data.get(CONF_NAME) or config.title, + ), + ), ) - async_add_entities((sensor,), True) - -class NextBusDepartureSensor(SensorEntity): +class NextBusDepartureSensor( + CoordinatorEntity[NextBusDataUpdateCoordinator], SensorEntity +): """Sensor class that displays upcoming NextBus times. To function, this requires knowing the agency tag as well as the tags for @@ -100,49 +107,57 @@ class NextBusDepartureSensor(SensorEntity): _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_icon = "mdi:bus" - def __init__(self, client, unique_id, agency, route, stop, name): + def __init__( + self, + coordinator: NextBusDataUpdateCoordinator, + unique_id: str, + agency: str, + route: str, + stop: str, + name: str, + ) -> None: """Initialize sensor with all required config.""" + super().__init__(coordinator) self.agency = agency self.route = route self.stop = stop - self._attr_extra_state_attributes = {} + self._attr_extra_state_attributes: dict[str, str] = {} self._attr_unique_id = unique_id self._attr_name = name - self._client = client - def _log_debug(self, message, *args): """Log debug message with prefix.""" _LOGGER.debug(":".join((self.agency, self.route, self.stop, message)), *args) - def update(self) -> None: + def _log_err(self, message, *args): + """Log error message with prefix.""" + _LOGGER.error(":".join((self.agency, self.route, self.stop, message)), *args) + + async def async_added_to_hass(self) -> None: + """Read data from coordinator after adding to hass.""" + self._handle_coordinator_update() + await super().async_added_to_hass() + + @callback + def _handle_coordinator_update(self) -> None: """Update sensor with new departures times.""" - # Note: using Multi because there is a bug with the single stop impl - results = self._client.get_predictions_for_multi_stops( - [{"stop_tag": self.stop, "route_tag": self.route}], self.agency - ) + results = self.coordinator.get_prediction_data(self.stop, self.route) + self._attr_attribution = self.coordinator.get_attribution() self._log_debug("Predictions results: %s", results) - self._attr_attribution = results.get("copyright") - if "Error" in results: - self._log_debug("Could not get predictions: %s", results) - - if not results.get("predictions"): - self._log_debug("No predictions available") + if not results or "Error" in results: + self._log_err("Error getting predictions: %s", str(results)) self._attr_native_value = None - # Remove attributes that may now be outdated self._attr_extra_state_attributes.pop("upcoming", None) return - results = results["predictions"] - # Set detailed attributes self._attr_extra_state_attributes.update( { - "agency": results.get("agencyTitle"), - "route": results.get("routeTitle"), - "stop": results.get("stopTitle"), + "agency": str(results.get("agencyTitle")), + "route": str(results.get("routeTitle")), + "stop": str(results.get("stopTitle")), } ) @@ -171,14 +186,15 @@ class NextBusDepartureSensor(SensorEntity): self._log_debug("No upcoming predictions available") self._attr_native_value = None self._attr_extra_state_attributes["upcoming"] = "No upcoming predictions" - return + else: + # Generate list of upcoming times + self._attr_extra_state_attributes["upcoming"] = ", ".join( + sorted((p["minutes"] for p in predictions), key=int) + ) - # Generate list of upcoming times - self._attr_extra_state_attributes["upcoming"] = ", ".join( - sorted((p["minutes"] for p in predictions), key=int) - ) + latest_prediction = maybe_first(predictions) + self._attr_native_value = utc_from_timestamp( + int(latest_prediction["epochTime"]) / 1000 + ) - latest_prediction = maybe_first(predictions) - self._attr_native_value = utc_from_timestamp( - int(latest_prediction["epochTime"]) / 1000 - ) + self.async_write_ha_state() diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 0133a9e7f76..16c8adb77ce 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -348,7 +348,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ key="server_php_max_execution_time", translation_key="nextcloud_server_php_max_execution_time", device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:language-php", native_unit_of_measurement=UnitOfTime.SECONDS, ), @@ -356,7 +356,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ key="server_php_memory_limit", translation_key="nextcloud_server_php_memory_limit", device_class=SensorDeviceClass.DATA_SIZE, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:language-php", native_unit_of_measurement=UnitOfInformation.BYTES, suggested_display_precision=1, @@ -366,7 +366,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ key="server_php_upload_max_filesize", translation_key="nextcloud_server_php_upload_max_filesize", device_class=SensorDeviceClass.DATA_SIZE, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:language-php", native_unit_of_measurement=UnitOfInformation.BYTES, suggested_display_precision=1, diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index cfe57f201ca..f9f7e4c2294 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -91,7 +91,7 @@ "name": "Cache ttl" }, "nextcloud_database_size": { - "name": "Databse size" + "name": "Database size" }, "nextcloud_database_type": { "name": "Database type" diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 2f13632dc46..ddd2e400dab 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.4.0"] + "requirements": ["nextdns==2.0.0"] } diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 2f15c4cd8e5..e0a37aad03b 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -275,7 +275,7 @@ "name": "Block Twitch" }, "block_twitter": { - "name": "Block Twitter" + "name": "Block X (formerly Twitter)" }, "block_video_streaming": { "name": "Block video streaming" diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index f57a4511eec..355ce84525f 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.2.0"] + "requirements": ["nibe==2.4.0"] } diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 1b3bc928985..8231cc65450 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -50,6 +50,8 @@ class Number(CoilEntity, NumberEntity): self._attr_native_min_value, self._attr_native_max_value, ) = _get_numeric_limits(coil.size) + self._attr_native_min_value /= coil.factor + self._attr_native_max_value /= coil.factor else: self._attr_native_min_value = float(coil.min) self._attr_native_max_value = float(coil.max) diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 4ac2518ffb6..435ea288aa7 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -1,21 +1,11 @@ """The Nina integration.""" from __future__ import annotations -import asyncio -from dataclasses import dataclass -import re -from typing import Any - -from pynina import ApiError, Nina - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - _LOGGER, ALL_MATCH_REGEX, CONF_AREA_FILTER, CONF_FILTER_CORONA, @@ -23,8 +13,8 @@ from .const import ( CONF_REGIONS, DOMAIN, NO_MATCH_REGEX, - SCAN_INTERVAL, ) +from .coordinator import NINADataUpdateCoordinator PLATFORMS: list[str] = [Platform.BINARY_SENSOR] @@ -74,126 +64,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -@dataclass -class NinaWarningData: - """Class to hold the warning data.""" - - id: str - headline: str - description: str - sender: str - severity: str - recommended_actions: str - affected_areas: str - sent: str - start: str - expires: str - is_valid: bool - - -class NINADataUpdateCoordinator( - DataUpdateCoordinator[dict[str, list[NinaWarningData]]] -): - """Class to manage fetching NINA data API.""" - - def __init__( - self, - hass: HomeAssistant, - regions: dict[str, str], - headline_filter: str, - area_filter: str, - ) -> None: - """Initialize.""" - self._regions: dict[str, str] = regions - self._nina: Nina = Nina(async_get_clientsession(hass)) - self.headline_filter: str = headline_filter - self.area_filter: str = area_filter - - for region in regions: - self._nina.addRegion(region) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - - async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: - """Update data.""" - async with asyncio.timeout(10): - try: - await self._nina.update() - except ApiError as err: - raise UpdateFailed(err) from err - return self._parse_data() - - @staticmethod - def _remove_duplicate_warnings( - warnings: dict[str, list[Any]] - ) -> dict[str, list[Any]]: - """Remove warnings with the same title and expires timestamp in a region.""" - all_filtered_warnings: dict[str, list[Any]] = {} - - for region_id, raw_warnings in warnings.items(): - filtered_warnings: list[Any] = [] - processed_details: list[tuple[str, str]] = [] - - for raw_warn in raw_warnings: - if (raw_warn.headline, raw_warn.expires) in processed_details: - continue - - processed_details.append((raw_warn.headline, raw_warn.expires)) - - filtered_warnings.append(raw_warn) - - all_filtered_warnings[region_id] = filtered_warnings - - return all_filtered_warnings - - def _parse_data(self) -> dict[str, list[NinaWarningData]]: - """Parse warning data.""" - - return_data: dict[str, list[NinaWarningData]] = {} - - for region_id, raw_warnings in self._remove_duplicate_warnings( - self._nina.warnings - ).items(): - warnings_for_regions: list[NinaWarningData] = [] - - for raw_warn in raw_warnings: - if re.search( - self.headline_filter, raw_warn.headline, flags=re.IGNORECASE - ): - _LOGGER.debug( - f"Ignore warning ({raw_warn.id}) by headline filter ({self.headline_filter}) with headline: {raw_warn.headline}" - ) - continue - - affected_areas_string: str = ", ".join( - [str(area) for area in raw_warn.affected_areas] - ) - - if not re.search( - self.area_filter, affected_areas_string, flags=re.IGNORECASE - ): - _LOGGER.debug( - f"Ignore warning ({raw_warn.id}) by area filter ({self.area_filter}) with area: {affected_areas_string}" - ) - continue - - warning_data: NinaWarningData = NinaWarningData( - raw_warn.id, - raw_warn.headline, - raw_warn.description, - raw_warn.sender, - raw_warn.severity, - " ".join([str(action) for action in raw_warn.recommended_actions]), - affected_areas_string, - raw_warn.sent or "", - raw_warn.start or "", - raw_warn.expires or "", - raw_warn.isValid(), - ) - warnings_for_regions.append(warning_data) - - return_data[region_id] = warnings_for_regions - - return return_data diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 19f802f1cec..568869ca402 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NINADataUpdateCoordinator from .const import ( ATTR_AFFECTED_AREAS, ATTR_DESCRIPTION, @@ -28,6 +27,7 @@ from .const import ( CONF_REGIONS, DOMAIN, ) +from .coordinator import NINADataUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py new file mode 100644 index 00000000000..eb5c7a7e506 --- /dev/null +++ b/homeassistant/components/nina/coordinator.py @@ -0,0 +1,138 @@ +"""DataUpdateCoordinator for the nina integration.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import re +from typing import Any + +from pynina import ApiError, Nina + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import _LOGGER, DOMAIN, SCAN_INTERVAL + + +@dataclass +class NinaWarningData: + """Class to hold the warning data.""" + + id: str + headline: str + description: str + sender: str + severity: str + recommended_actions: str + affected_areas: str + sent: str + start: str + expires: str + is_valid: bool + + +class NINADataUpdateCoordinator( + DataUpdateCoordinator[dict[str, list[NinaWarningData]]] +): + """Class to manage fetching NINA data API.""" + + def __init__( + self, + hass: HomeAssistant, + regions: dict[str, str], + headline_filter: str, + area_filter: str, + ) -> None: + """Initialize.""" + self._regions: dict[str, str] = regions + self._nina: Nina = Nina(async_get_clientsession(hass)) + self.headline_filter: str = headline_filter + self.area_filter: str = area_filter + + for region in regions: + self._nina.addRegion(region) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: + """Update data.""" + async with asyncio.timeout(10): + try: + await self._nina.update() + except ApiError as err: + raise UpdateFailed(err) from err + return self._parse_data() + + @staticmethod + def _remove_duplicate_warnings( + warnings: dict[str, list[Any]] + ) -> dict[str, list[Any]]: + """Remove warnings with the same title and expires timestamp in a region.""" + all_filtered_warnings: dict[str, list[Any]] = {} + + for region_id, raw_warnings in warnings.items(): + filtered_warnings: list[Any] = [] + processed_details: list[tuple[str, str]] = [] + + for raw_warn in raw_warnings: + if (raw_warn.headline, raw_warn.expires) in processed_details: + continue + + processed_details.append((raw_warn.headline, raw_warn.expires)) + + filtered_warnings.append(raw_warn) + + all_filtered_warnings[region_id] = filtered_warnings + + return all_filtered_warnings + + def _parse_data(self) -> dict[str, list[NinaWarningData]]: + """Parse warning data.""" + + return_data: dict[str, list[NinaWarningData]] = {} + + for region_id, raw_warnings in self._remove_duplicate_warnings( + self._nina.warnings + ).items(): + warnings_for_regions: list[NinaWarningData] = [] + + for raw_warn in raw_warnings: + if re.search( + self.headline_filter, raw_warn.headline, flags=re.IGNORECASE + ): + _LOGGER.debug( + f"Ignore warning ({raw_warn.id}) by headline filter ({self.headline_filter}) with headline: {raw_warn.headline}" + ) + continue + + affected_areas_string: str = ", ".join( + [str(area) for area in raw_warn.affected_areas] + ) + + if not re.search( + self.area_filter, affected_areas_string, flags=re.IGNORECASE + ): + _LOGGER.debug( + f"Ignore warning ({raw_warn.id}) by area filter ({self.area_filter}) with area: {affected_areas_string}" + ) + continue + + warning_data: NinaWarningData = NinaWarningData( + raw_warn.id, + raw_warn.headline, + raw_warn.description, + raw_warn.sender, + raw_warn.severity, + " ".join([str(action) for action in raw_warn.recommended_actions]), + affected_areas_string, + raw_warn.sent or "", + raw_warn.start or "", + raw_warn.expires or "", + raw_warn.isValid(), + ) + warnings_for_regions.append(warning_data) + + return_data[region_id] = warnings_for_regions + + return return_data diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 4daaee10ea6..13a46c0b32f 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -251,6 +251,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): """Return the device_info of the device.""" return DeviceInfo( identifiers={(DOMAIN, self._thermostat.serial_number)}, + serial_number=self._thermostat.serial_number, name=self._thermostat.room, model="nVent Signature", manufacturer=MANUFACTURER, diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 3b846d73477..ede7a20ccdb 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections import defaultdict +from dataclasses import dataclass from datetime import timedelta from http import HTTPStatus import logging @@ -38,15 +39,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import ( - DATA_BRIDGE, - DATA_COORDINATOR, - DATA_LOCKS, - DATA_OPENERS, - DEFAULT_TIMEOUT, - DOMAIN, - ERROR_STATES, -) +from .const import DEFAULT_TIMEOUT, DOMAIN, ERROR_STATES from .helpers import NukiWebhookException, parse_id _NukiDeviceT = TypeVar("_NukiDeviceT", bound=NukiDevice) @@ -57,6 +50,16 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] UPDATE_INTERVAL = timedelta(seconds=30) +@dataclass(slots=True) +class NukiEntryData: + """Class to hold Nuki data.""" + + coordinator: NukiCoordinator + bridge: NukiBridge + locks: list[NukiLock] + openers: list[NukiOpener] + + def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOpener]]: return bridge.locks, bridge.openers @@ -74,14 +77,15 @@ async def _create_webhook( except ValueError: return web.Response(status=HTTPStatus.BAD_REQUEST) - locks = hass.data[DOMAIN][entry.entry_id][DATA_LOCKS] - openers = hass.data[DOMAIN][entry.entry_id][DATA_OPENERS] + entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + locks = entry_data.locks + openers = entry_data.openers devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]] if len(devices) == 1: devices[0].update_from_callback(data) - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator = entry_data.coordinator coordinator.async_set_updated_data(None) return web.Response(status=HTTPStatus.OK) @@ -232,13 +236,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = NukiCoordinator(hass, bridge, locks, openers) - - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_BRIDGE: bridge, - DATA_LOCKS: locks, - DATA_OPENERS: openers, - } + hass.data[DOMAIN][entry.entry_id] = NukiEntryData( + coordinator=coordinator, + bridge=bridge, + locks=locks, + openers=openers, + ) # Fetch initial data so we have data when entities subscribe await coordinator.async_refresh() @@ -251,11 +254,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Nuki entry.""" webhook.async_unregister(hass, entry.entry_id) + entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + try: async with asyncio.timeout(10): await hass.async_add_executor_job( _remove_webhook, - hass.data[DOMAIN][entry.entry_id][DATA_BRIDGE], + entry_data.bridge, entry.entry_id, ) except InvalidCredentialsException as err: diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 86c7f8343df..240bb2dc525 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -12,22 +12,21 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NukiCoordinator, NukiEntity -from .const import ATTR_NUKI_ID, DATA_COORDINATOR, DATA_LOCKS, DOMAIN as NUKI_DOMAIN +from . import NukiEntity, NukiEntryData +from .const import ATTR_NUKI_ID, DOMAIN as NUKI_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nuki lock binary sensor.""" - data = hass.data[NUKI_DOMAIN][entry.entry_id] - coordinator: NukiCoordinator = data[DATA_COORDINATOR] + entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] entities = [] - for lock in data[DATA_LOCKS]: + for lock in entry_data.locks: if lock.is_door_sensor_activated: - entities.extend([NukiDoorsensorEntity(coordinator, lock)]) + entities.extend([NukiDoorsensorEntity(entry_data.coordinator, lock)]) async_add_entities(entities) diff --git a/homeassistant/components/nuki/const.py b/homeassistant/components/nuki/const.py index 680454c3edc..dee4a8b8ac5 100644 --- a/homeassistant/components/nuki/const.py +++ b/homeassistant/components/nuki/const.py @@ -7,12 +7,6 @@ ATTR_NUKI_ID = "nuki_id" ATTR_ENABLE = "enable" ATTR_UNLATCH = "unlatch" -# Data -DATA_BRIDGE = "nuki_bridge_data" -DATA_LOCKS = "nuki_locks_data" -DATA_OPENERS = "nuki_openers_data" -DATA_COORDINATOR = "nuki_coordinator" - # Defaults DEFAULT_PORT = 8080 DEFAULT_TIMEOUT = 20 diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index a1a75ef8260..f1e553e6668 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -16,15 +16,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NukiCoordinator, NukiEntity +from . import NukiEntity, NukiEntryData from .const import ( ATTR_BATTERY_CRITICAL, ATTR_ENABLE, ATTR_NUKI_ID, ATTR_UNLATCH, - DATA_COORDINATOR, - DATA_LOCKS, - DATA_OPENERS, DOMAIN as NUKI_DOMAIN, ERROR_STATES, ) @@ -37,14 +34,14 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nuki lock platform.""" - data = hass.data[NUKI_DOMAIN][entry.entry_id] - coordinator: NukiCoordinator = data[DATA_COORDINATOR] + entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] + coordinator = entry_data.coordinator entities: list[NukiDeviceEntity] = [ - NukiLockEntity(coordinator, lock) for lock in data[DATA_LOCKS] + NukiLockEntity(coordinator, lock) for lock in entry_data.locks ] entities.extend( - [NukiOpenerEntity(coordinator, opener) for opener in data[DATA_OPENERS]] + [NukiOpenerEntity(coordinator, opener) for opener in entry_data.openers] ) async_add_entities(entities) diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 06cfa065c54..3c6775cd171 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -9,19 +9,18 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NukiEntity -from .const import ATTR_NUKI_ID, DATA_COORDINATOR, DATA_LOCKS, DOMAIN as NUKI_DOMAIN +from . import NukiEntity, NukiEntryData +from .const import ATTR_NUKI_ID, DOMAIN as NUKI_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nuki lock sensor.""" - data = hass.data[NUKI_DOMAIN][entry.entry_id] - coordinator = data[DATA_COORDINATOR] + entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] async_add_entities( - NukiBatterySensor(coordinator, lock) for lock in data[DATA_LOCKS] + NukiBatterySensor(entry_data.coordinator, lock) for lock in entry_data.locks ) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 4e0f5059c90..201fa8fedb6 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -5,7 +5,6 @@ from collections.abc import Callable from contextlib import suppress import dataclasses from datetime import timedelta -import inspect import logging from math import ceil, floor from typing import Any, Self, final @@ -14,7 +13,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, async_get_hass, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -23,6 +23,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_suggest_report_issue from .const import ( # noqa: F401 ATTR_MAX, @@ -192,14 +193,10 @@ class NumberEntity(Entity): "value", ) ): - module = inspect.getmodule(cls) - if module and module.__file__ and "custom_components" in module.__file__: - report_issue = "report it to the custom integration author." - else: - report_issue = ( - "create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + report_issue = async_suggest_report_issue(hass, module=cls.__module__) _LOGGER.warning( ( "%s::%s is overriding deprecated methods on an instance of " @@ -221,10 +218,17 @@ class NumberEntity(Entity): @property def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" + device_class = self.device_class + min_value = self._convert_to_state_value( + self.native_min_value, floor_decimal, device_class + ) + max_value = self._convert_to_state_value( + self.native_max_value, ceil_decimal, device_class + ) return { - ATTR_MIN: self.min_value, - ATTR_MAX: self.max_value, - ATTR_STEP: self.step, + ATTR_MIN: min_value, + ATTR_MAX: max_value, + ATTR_STEP: self._calculate_step(min_value, max_value), ATTR_MODE: self.mode, } @@ -260,7 +264,9 @@ class NumberEntity(Entity): @final def min_value(self) -> float: """Return the minimum value.""" - return self._convert_to_state_value(self.native_min_value, floor_decimal) + return self._convert_to_state_value( + self.native_min_value, floor_decimal, self.device_class + ) @property def native_max_value(self) -> float: @@ -278,7 +284,9 @@ class NumberEntity(Entity): @final def max_value(self) -> float: """Return the maximum value.""" - return self._convert_to_state_value(self.native_max_value, ceil_decimal) + return self._convert_to_state_value( + self.native_max_value, ceil_decimal, self.device_class + ) @property def native_step(self) -> float | None: @@ -293,13 +301,17 @@ class NumberEntity(Entity): @property @final def step(self) -> float: + """Return the increment/decrement step.""" + return self._calculate_step(self.min_value, self.max_value) + + def _calculate_step(self, min_value: float, max_value: float) -> float: """Return the increment/decrement step.""" if hasattr(self, "_attr_native_step"): return self._attr_native_step if (native_step := self.native_step) is not None: return native_step step = DEFAULT_STEP - value_range = abs(self.max_value - self.min_value) + value_range = abs(max_value - min_value) if value_range != 0: while value_range <= step: step /= 10.0 @@ -340,11 +352,12 @@ class NumberEntity(Entity): return self._number_option_unit_of_measurement native_unit_of_measurement = self.native_unit_of_measurement - + # device_class is checked after native_unit_of_measurement since most + # of the time we can avoid the device_class check if ( - self.device_class == NumberDeviceClass.TEMPERATURE - and native_unit_of_measurement + native_unit_of_measurement in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT) + and self.device_class == NumberDeviceClass.TEMPERATURE ): return self.hass.config.units.temperature_unit @@ -361,7 +374,7 @@ class NumberEntity(Entity): """Return the entity value to represent the entity state.""" if (native_value := self.native_value) is None: return native_value - return self._convert_to_state_value(native_value, round) + return self._convert_to_state_value(native_value, round, self.device_class) def set_native_value(self, value: float) -> None: """Set new value.""" @@ -382,17 +395,20 @@ class NumberEntity(Entity): await self.hass.async_add_executor_job(self.set_value, value) def _convert_to_state_value( - self, value: float, method: Callable[[float, int], float] + self, + value: float, + method: Callable[[float, int], float], + device_class: NumberDeviceClass | None, ) -> float: """Convert a value in the number's native unit to the configured unit.""" + # device_class is checked first since most of the time we can avoid + # the unit conversion + if device_class not in UNIT_CONVERTERS: + return value + native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement - device_class = self.device_class - - if ( - native_unit_of_measurement != unit_of_measurement - and device_class in UNIT_CONVERTERS - ): + if native_unit_of_measurement != unit_of_measurement: assert native_unit_of_measurement assert unit_of_measurement @@ -414,15 +430,14 @@ class NumberEntity(Entity): def convert_to_native_value(self, value: float) -> float: """Convert a value to the number's native unit.""" + # device_class is checked first since most of the time we can avoid + # the unit conversion + if value is None or (device_class := self.device_class) not in UNIT_CONVERTERS: + return value + native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement - device_class = self.device_class - - if ( - value is not None - and native_unit_of_measurement != unit_of_measurement - and device_class in UNIT_CONVERTERS - ): + if native_unit_of_measurement != unit_of_measurement: assert native_unit_of_measurement assert unit_of_measurement diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 3041ac38726..c897542e666 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -28,7 +28,7 @@ STATE_TYPES = { "OB": "On Battery", "LB": "Low Battery", "HB": "High Battery", - "RB": "Battery Needs Replaced", + "RB": "Battery Needs Replacement", "CHRG": "Battery Charging", "DISCHRG": "Battery Discharging", "BYPASS": "Bypass Active", diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 6185adb70a1..02899dbc1a2 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -20,6 +20,11 @@ VIDEO_SOURCE_MAPPING = { } +def extract_message(msg: Any) -> tuple[str, Any]: + """Extract the message content and the topic.""" + return msg.Topic._value_1, msg.Message._value_1 # pylint: disable=protected-access + + def _normalize_video_source(source: str) -> str: """Normalize video source. @@ -48,16 +53,15 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/MotionAlarm """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Motion Alarm", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -72,16 +76,15 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBlurry/* """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Image Too Blurry", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -97,16 +100,15 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooDark/* """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Image Too Dark", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -122,16 +124,15 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBright/* """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Image Too Bright", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -147,16 +148,15 @@ async def async_parse_scene_change(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/GlobalSceneChange/* """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Global Scene Change", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -172,9 +172,8 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: audio_source = "" audio_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "AudioSourceConfigurationToken": audio_source = source.Value if source.Name == "AudioAnalyticsConfigurationToken": @@ -183,12 +182,12 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{audio_source}_{audio_analytics}_{rule}", + f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", "Detected Sound", "binary_sensor", "sound", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -204,9 +203,8 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -215,12 +213,12 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = source.Value evt = Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Field Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) return evt except (AttributeError, KeyError): @@ -237,9 +235,8 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -248,12 +245,12 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Cell Motion Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -269,9 +266,8 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -280,12 +276,12 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Motion Region Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value in ["1", "true"], + payload.Data.SimpleItem[0].Value in ["1", "true"], ) except (AttributeError, KeyError): return None @@ -301,9 +297,8 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -312,12 +307,12 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Tamper Detection", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -332,19 +327,18 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Pet Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -358,19 +352,18 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Vehicle Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -384,19 +377,18 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Person Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -410,19 +402,18 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Face Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -436,19 +427,18 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Visitor Detection", "binary_sensor", "occupancy", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -461,16 +451,15 @@ async def async_parse_digital_input(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/DigitalInput """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Digital Input", "binary_sensor", None, None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -483,16 +472,15 @@ async def async_parse_relay(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/Relay """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Relay Triggered", "binary_sensor", None, None, - message_value.Data.SimpleItem[0].Value == "active", + payload.Data.SimpleItem[0].Value == "active", ) except (AttributeError, KeyError): return None @@ -505,16 +493,15 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: Topic: tns1:Device/HardwareFailure/StorageFailure """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Storage Failure", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -528,14 +515,13 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/ProcessorUsage """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - usage = float(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + usage = float(payload.Data.SimpleItem[0].Value) if usage <= 1: usage *= 100 return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Processor Usage", "sensor", None, @@ -554,11 +540,10 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReboot """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Last Reboot", "sensor", "timestamp", @@ -577,11 +562,10 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReset """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Last Reset", "sensor", "timestamp", @@ -602,11 +586,10 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Last Backup", "sensor", "timestamp", @@ -626,11 +609,10 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Last Clock Synchronization", "sensor", "timestamp", @@ -651,16 +633,15 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Recording Job State", "binary_sensor", None, None, - message_value.Data.SimpleItem[0].Value == "Active", + payload.Data.SimpleItem[0].Value == "Active", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -677,9 +658,8 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = source.Value if source.Name == "VideoAnalyticsConfigurationToken": @@ -688,12 +668,12 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Line Detector Crossed", "sensor", None, None, - message_value.Data.SimpleItem[0].Value, + payload.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -710,9 +690,8 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -721,12 +700,12 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Count Aggregation Counter", "sensor", None, None, - message_value.Data.SimpleItem[0].Value, + payload.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index 4d1047222ff..d33dfec6adf 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.2.0"] + "requirements": ["python-opensky==0.2.1"] } diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 4df91cf4e15..048ffdd237b 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_SENSORS, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -132,19 +132,3 @@ class OpenUvEntity(CoordinatorEntity): name="OpenUV", entry_type=DeviceEntryType.SERVICE, ) - - @callback - def _handle_coordinator_update(self) -> None: - """Respond to a DataUpdateCoordinator update.""" - self._update_from_latest_data() - self.async_write_ha_state() - - @callback - def _update_from_latest_data(self) -> None: - """Update the entity from the latest data.""" - raise NotImplementedError - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - self._update_from_latest_data() diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index e9f9ee99ff6..9c970f34dc3 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -45,7 +45,7 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): """Define a binary sensor for OpenUV.""" @callback - def _update_from_latest_data(self) -> None: + def _handle_coordinator_update(self) -> None: """Update the entity from the latest data.""" data = self.coordinator.data @@ -76,3 +76,5 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): ATTR_PROTECTION_WINDOW_STARTING_TIME: as_local(from_dt), } ) + + super()._handle_coordinator_update() diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py index 5d0c4bce50a..f82a85e19b0 100644 --- a/homeassistant/components/openuv/coordinator.py +++ b/homeassistant/components/openuv/coordinator.py @@ -17,7 +17,7 @@ from .const import LOGGER DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 -class OpenUvCoordinator(DataUpdateCoordinator): +class OpenUvCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an OpenUV data coordinator.""" config_entry: ConfigEntry diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 90eefac594a..8434b6d5591 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,14 +1,19 @@ """Support for OpenUV sensors.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UV_INDEX, UnitOfTime -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime @@ -40,79 +45,137 @@ EXPOSURE_TYPE_MAP = { TYPE_SAFE_EXPOSURE_TIME_6: "st6", } -UV_LEVEL_EXTREME = "Extreme" -UV_LEVEL_VHIGH = "Very High" -UV_LEVEL_HIGH = "High" -UV_LEVEL_MODERATE = "Moderate" -UV_LEVEL_LOW = "Low" + +@dataclass +class UvLabel: + """Define a friendly UV level label and its minimum UV index.""" + + value: str + minimum_index: int + + +UV_LABEL_DEFINITIONS = ( + UvLabel(value="extreme", minimum_index=11), + UvLabel(value="very_high", minimum_index=8), + UvLabel(value="high", minimum_index=6), + UvLabel(value="moderate", minimum_index=3), + UvLabel(value="low", minimum_index=0), +) + + +def get_uv_label(uv_index: int) -> str: + """Return the UV label for the UV index.""" + label = next( + label for label in UV_LABEL_DEFINITIONS if uv_index >= label.minimum_index + ) + return label.value + + +@dataclass +class OpenUvSensorEntityDescriptionMixin: + """Define a mixin for OpenUV sensor descriptions.""" + + value_fn: Callable[[dict[str, Any]], int | str] + + +@dataclass +class OpenUvSensorEntityDescription( + SensorEntityDescription, OpenUvSensorEntityDescriptionMixin +): + """Define a class that describes OpenUV sensor entities.""" + SENSOR_DESCRIPTIONS = ( - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_CURRENT_OZONE_LEVEL, translation_key="current_ozone_level", native_unit_of_measurement="du", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["ozone"], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_CURRENT_UV_INDEX, translation_key="current_uv_index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["uv"], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_CURRENT_UV_LEVEL, translation_key="current_uv_level", icon="mdi:weather-sunny", + device_class=SensorDeviceClass.ENUM, + options=[label.value for label in UV_LABEL_DEFINITIONS], + value_fn=lambda data: get_uv_label(data["uv"]), ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_MAX_UV_INDEX, translation_key="max_uv_index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["uv_max"], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_1, translation_key="skin_type_1_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_1] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_2, translation_key="skin_type_2_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_2] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_3, translation_key="skin_type_3_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_3] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_4, translation_key="skin_type_4_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_4] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_5, translation_key="skin_type_5_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_5] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_6, translation_key="skin_type_6_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_6] + ], ), ) @@ -134,40 +197,18 @@ async def async_setup_entry( class OpenUvSensor(OpenUvEntity, SensorEntity): """Define a binary sensor for OpenUV.""" - @callback - def _update_from_latest_data(self) -> None: - """Update the state.""" - data = self.coordinator.data + entity_description: OpenUvSensorEntityDescription - if self.entity_description.key == TYPE_CURRENT_OZONE_LEVEL: - self._attr_native_value = data["ozone"] - elif self.entity_description.key == TYPE_CURRENT_UV_INDEX: - self._attr_native_value = data["uv"] - elif self.entity_description.key == TYPE_CURRENT_UV_LEVEL: - if data["uv"] >= 11: - self._attr_native_value = UV_LEVEL_EXTREME - elif data["uv"] >= 8: - self._attr_native_value = UV_LEVEL_VHIGH - elif data["uv"] >= 6: - self._attr_native_value = UV_LEVEL_HIGH - elif data["uv"] >= 3: - self._attr_native_value = UV_LEVEL_MODERATE - else: - self._attr_native_value = UV_LEVEL_LOW - elif self.entity_description.key == TYPE_MAX_UV_INDEX: - self._attr_native_value = data["uv_max"] - if uv_max_time := parse_datetime(data["uv_max_time"]): - self._attr_extra_state_attributes.update( - {ATTR_MAX_UV_TIME: as_local(uv_max_time)} - ) - elif self.entity_description.key in ( - TYPE_SAFE_EXPOSURE_TIME_1, - TYPE_SAFE_EXPOSURE_TIME_2, - TYPE_SAFE_EXPOSURE_TIME_3, - TYPE_SAFE_EXPOSURE_TIME_4, - TYPE_SAFE_EXPOSURE_TIME_5, - TYPE_SAFE_EXPOSURE_TIME_6, - ): - self._attr_native_value = data["safe_exposure_time"][ - EXPOSURE_TYPE_MAP[self.entity_description.key] - ] + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return entity specific state attributes.""" + attrs = {} + if self.entity_description.key == TYPE_MAX_UV_INDEX: + if uv_max_time := parse_datetime(self.coordinator.data["uv_max_time"]): + attrs[ATTR_MAX_UV_TIME] = as_local(uv_max_time) + return attrs + + @property + def native_value(self) -> int | str: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index 2534622975c..9349d2cc116 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -51,7 +51,14 @@ "name": "Current UV index" }, "current_uv_level": { - "name": "Current UV level" + "name": "Current UV level", + "state": { + "extreme": "Extreme", + "high": "High", + "low": "Low", + "moderate": "Moderate", + "very_high": "Very high" + } }, "max_uv_index": { "name": "Max UV index" diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 232664d5b6b..a1e0e9d2169 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -19,6 +19,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -123,15 +124,15 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=ATTR_API_RAIN, name="Rain", - native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, - device_class=SensorDeviceClass.PRECIPITATION, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_SNOW, name="Snow", - native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, - device_class=SensorDeviceClass.PRECIPITATION, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 5ce35e949af..239f23e7523 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -23,7 +23,7 @@ from homeassistant.components.recorder.statistics import ( statistics_during_period, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -58,6 +58,16 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): entry_data.get(CONF_TOTP_SECRET), ) + @callback + def _dummy_listener() -> None: + pass + + # Force the coordinator to periodically update by registering at least one listener. + # Needed when the _async_update_data below returns {} for utilities that don't provide + # forecast, which results to no sensors added, no registered listeners, and thus + # _async_update_data not periodically getting called which is needed for _insert_statistics. + self.async_add_listener(_dummy_listener) + async def _async_update_data( self, ) -> dict[str, Forecast]: @@ -71,6 +81,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): raise ConfigEntryAuthFailed from err forecasts: list[Forecast] = await self.api.async_get_forecast() _LOGGER.debug("Updating sensor data with: %s", forecasts) + # Because Opower provides historical usage/cost with a delay of a couple of days + # we need to insert data into statistics. await self._insert_statistics() return {forecast.account.utility_account_id: forecast for forecast in forecasts} diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 02c73238ef9..a27d6f6f680 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.36"] + "requirements": ["opower==0.0.38"] } diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index ab463fc34d9..6ca082ace76 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from aiohttp import ClientError, ServerDisconnectedError from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.enums import OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, @@ -17,9 +18,9 @@ from pyoverkiz.models import Device, Scenario from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( @@ -55,6 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: username=username, password=password, session=session, server=server ) + await _async_migrate_entries(hass, entry) + try: await client.login() @@ -144,3 +147,62 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Migrate old entries to new unique IDs.""" + entity_registry = er.async_get(hass) + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: + # Python 3.11 treats (str, Enum) and StrEnum in a different way + # Since pyOverkiz switched to StrEnum, we need to rewrite the unique ids once to the new style + # + # io://xxxx-xxxx-xxxx/3541212-OverkizState.CORE_DISCRETE_RSSI_LEVEL -> io://xxxx-xxxx-xxxx/3541212-core:DiscreteRSSILevelState + # internal://xxxx-xxxx-xxxx/alarm/0-UIWidget.TSKALARM_CONTROLLER -> internal://xxxx-xxxx-xxxx/alarm/0-TSKAlarmController + # io://xxxx-xxxx-xxxx/xxxxxxx-UIClass.ON_OFF -> io://xxxx-xxxx-xxxx/xxxxxxx-OnOff + if (key := entry.unique_id.split("-")[-1]).startswith( + ("OverkizState", "UIWidget", "UIClass") + ): + state = key.split(".")[1] + new_key = "" + + if key.startswith("UIClass"): + new_key = UIClass[state] + elif key.startswith("UIWidget"): + new_key = UIWidget[state] + else: + new_key = OverkizState[state] + + new_unique_id = entry.unique_id.replace(key, new_key) + + LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + LOGGER.debug( + "Cannot migrate to unique_id '%s', already exists for '%s'. Entity will be removed", + new_unique_id, + existing_entity_id, + ) + entity_registry.async_remove(entry.entity_id) + + return None + + return { + "new_unique_id": new_unique_id, + } + + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + return True diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index d88996c7e02..3b3afddc489 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.9.0"], + "requirements": ["pyoverkiz==1.12.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json index 2486e01223f..499b598d7ae 100644 --- a/homeassistant/components/owntracks/strings.json +++ b/homeassistant/components/owntracks/strings.json @@ -11,7 +11,7 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { - "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to Preferences > Connection. Change the following settings:\n - Mode: HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `''`\n - Device ID: `''`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left > Settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `''`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to Preferences > Connection. Change the following settings:\n - Mode: HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `'(Your name)'`\n - Device ID: `'(Your device name)'`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left > Settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `'(Your name)'`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." } } } diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index a159c47a7c9..9e7fe4168ab 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -69,42 +69,28 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.BROWSE_MEDIA ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, remote, name, device_info): """Initialize the entity.""" self._remote = remote - self._name = name - self._device_info = device_info - - @property - def unique_id(self): - """Return the unique ID of the device.""" - if self._device_info is None: - return None - return self._device_info[ATTR_UDN] - - @property - def device_info(self) -> DeviceInfo | None: - """Return device specific attributes.""" - if self._device_info is None: - return None - return DeviceInfo( - identifiers={(DOMAIN, self._device_info[ATTR_UDN])}, - manufacturer=self._device_info.get(ATTR_MANUFACTURER, DEFAULT_MANUFACTURER), - model=self._device_info.get(ATTR_MODEL_NUMBER, DEFAULT_MODEL_NUMBER), - name=self._name, - ) + if device_info is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_info[ATTR_UDN])}, + manufacturer=device_info.get(ATTR_MANUFACTURER, DEFAULT_MANUFACTURER), + model=device_info.get(ATTR_MODEL_NUMBER, DEFAULT_MODEL_NUMBER), + name=name, + ) + self._attr_unique_id = device_info[ATTR_UDN] + else: + self._attr_name = name @property def device_class(self): """Return the device class of the device.""" return MediaPlayerDeviceClass.TV - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def state(self): """Return the state of the device.""" diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index c9e8e3703db..9ecb91bdb7f 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -30,10 +30,6 @@ ATTR_TITLE: Final = "title" ATTR_STATUS: Final = "status" -# Remove EVENT_PERSISTENT_NOTIFICATIONS_UPDATED in Home Assistant 2023.9 -EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" - - class Notification(TypedDict): """Persistent notification.""" diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index 9b7e52c2119..d1cd3e7b1a5 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -17,6 +17,11 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaFlowFormStep, + SchemaOptionsFlowHandler, +) from . import LOGGER from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN @@ -33,6 +38,15 @@ USER_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ALLOW_NOTIFY, default=False): selector.BooleanSelector(), + } +) +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(OPTIONS_SCHEMA), +} + async def _validate_input( hass: core.HomeAssistant, host: str, api_version: int @@ -176,31 +190,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @core.callback def async_get_options_flow( config_entry: config_entries.ConfigEntry, - ) -> OptionsFlowHandler: + ) -> SchemaOptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for AEMET.""" - - 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: - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - data_schema = vol.Schema( - { - vol.Required( - CONF_ALLOW_NOTIFY, - default=self.config_entry.options.get(CONF_ALLOW_NOTIFY), - ): bool, - } - ) - return self.async_show_form(step_id="init", data_schema=data_schema) + return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index a260d42feda..6c738a36df3 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -30,7 +30,10 @@ "step": { "init": { "data": { - "allow_notify": "Allow usage of data notification service." + "allow_notify": "Allow notification service" + }, + "data_description": { + "allow_notify": "Allow the usage of data notification service on TV instead of periodic polling. This allows faster reaction to state changes on the TV, however, some TV's will stop responding with this activated due to firmware bugs." } } } diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 904b68e3d32..65ae201482a 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -107,7 +107,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Abort if we're adding a new config and the unique id is already in use, else create the entry if self.source != SOURCE_REAUTH: self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=data) + return self.async_create_entry(title="Picnic", data=data) # In case of re-auth, only continue if an exiting account exists with the same unique id if existing_entry: diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 6e35c27bbfb..e7a69e0bf02 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -24,7 +24,6 @@ from homeassistant.helpers.update_coordinator import ( from homeassistant.util import dt as dt_util from .const import ( - ADDRESS, ATTRIBUTION, CONF_COORDINATOR, DOMAIN, @@ -255,15 +254,12 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): super().__init__(coordinator) self.entity_description = description - self.entity_id = f"sensor.picnic_{description.key}" - self._attr_unique_id = f"{config_entry.unique_id}.{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, cast(str, config_entry.unique_id))}, manufacturer="Picnic", model=config_entry.unique_id, - name=f"Picnic: {coordinator.data[ADDRESS]}", ) @property diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 3ff36f2e283..26dd8113231 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -1,6 +1,7 @@ """The ping component.""" from __future__ import annotations +from dataclasses import dataclass import logging from icmplib import SocketPermissionError, ping as icmp_ping @@ -10,19 +11,28 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, PING_PRIVS, PLATFORMS +from .const import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) +@dataclass(slots=True) +class PingDomainData: + """Dataclass to store privileged status.""" + + privileged: bool | None + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ping integration.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - hass.data[DOMAIN] = { - PING_PRIVS: await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege), - } + + hass.data[DOMAIN] = PingDomainData( + privileged=await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege), + ) + return True diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 6a150b3dc4c..b120c453195 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -1,14 +1,10 @@ """Tracks the latency of a host by sending ICMP echo requests (ping).""" from __future__ import annotations -import asyncio -from contextlib import suppress from datetime import timedelta import logging -import re -from typing import TYPE_CHECKING, Any +from typing import Any -from icmplib import NameLookupError, async_ping import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -23,7 +19,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN, ICMP_TIMEOUT, PING_PRIVS, PING_TIMEOUT +from . import PingDomainData +from .const import DOMAIN +from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) @@ -42,16 +40,6 @@ SCAN_INTERVAL = timedelta(minutes=5) PARALLEL_UPDATES = 50 -PING_MATCHER = re.compile( - r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" -) - -PING_MATCHER_BUSYBOX = re.compile( - r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" -) - -WIN32_PING_MATCHER = re.compile(r"(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms") - PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -70,10 +58,13 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Ping Binary sensor.""" + + data: PingDomainData = hass.data[DOMAIN] + host: str = config[CONF_HOST] count: int = config[CONF_PING_COUNT] name: str = config.get(CONF_NAME, f"{DEFAULT_NAME} {host}") - privileged: bool | None = hass.data[DOMAIN][PING_PRIVS] + privileged: bool | None = data.privileged ping_cls: type[PingDataSubProcess | PingDataICMPLib] if privileged is None: ping_cls = PingDataSubProcess @@ -138,140 +129,3 @@ class PingBinarySensor(RestoreEntity, BinarySensorEntity): "avg": attributes[ATTR_ROUND_TRIP_TIME_AVG], "mdev": attributes[ATTR_ROUND_TRIP_TIME_MDEV], } - - -class PingData: - """The base class for handling the data retrieval.""" - - def __init__(self, hass: HomeAssistant, host: str, count: int) -> None: - """Initialize the data object.""" - self.hass = hass - self._ip_address = host - self._count = count - self.data: dict[str, Any] | None = None - self.is_alive = False - - -class PingDataICMPLib(PingData): - """The Class for handling the data retrieval using icmplib.""" - - def __init__( - self, hass: HomeAssistant, host: str, count: int, privileged: bool | None - ) -> None: - """Initialize the data object.""" - super().__init__(hass, host, count) - self._privileged = privileged - - async def async_update(self) -> None: - """Retrieve the latest details from the host.""" - _LOGGER.debug("ping address: %s", self._ip_address) - try: - data = await async_ping( - self._ip_address, - count=self._count, - timeout=ICMP_TIMEOUT, - privileged=self._privileged, - ) - except NameLookupError: - self.is_alive = False - return - - self.is_alive = data.is_alive - if not self.is_alive: - self.data = None - return - - self.data = { - "min": data.min_rtt, - "max": data.max_rtt, - "avg": data.avg_rtt, - "mdev": "", - } - - -class PingDataSubProcess(PingData): - """The Class for handling the data retrieval using the ping binary.""" - - def __init__( - self, hass: HomeAssistant, host: str, count: int, privileged: bool | None - ) -> None: - """Initialize the data object.""" - super().__init__(hass, host, count) - self._ping_cmd = [ - "ping", - "-n", - "-q", - "-c", - str(self._count), - "-W1", - self._ip_address, - ] - - async def async_ping(self) -> dict[str, Any] | None: - """Send ICMP echo request and return details if success.""" - pinger = await asyncio.create_subprocess_exec( - *self._ping_cmd, - stdin=None, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - close_fds=False, # required for posix_spawn - ) - try: - async with asyncio.timeout(self._count + PING_TIMEOUT): - out_data, out_error = await pinger.communicate() - - if out_data: - _LOGGER.debug( - "Output of command: `%s`, return code: %s:\n%s", - " ".join(self._ping_cmd), - pinger.returncode, - out_data, - ) - if out_error: - _LOGGER.debug( - "Error of command: `%s`, return code: %s:\n%s", - " ".join(self._ping_cmd), - pinger.returncode, - out_error, - ) - - if pinger.returncode and pinger.returncode > 1: - # returncode of 1 means the host is unreachable - _LOGGER.exception( - "Error running command: `%s`, return code: %s", - " ".join(self._ping_cmd), - pinger.returncode, - ) - - if "max/" not in str(out_data): - match = PING_MATCHER_BUSYBOX.search( - str(out_data).rsplit("\n", maxsplit=1)[-1] - ) - if TYPE_CHECKING: - assert match is not None - rtt_min, rtt_avg, rtt_max = match.groups() - return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": ""} - match = PING_MATCHER.search(str(out_data).rsplit("\n", maxsplit=1)[-1]) - if TYPE_CHECKING: - assert match is not None - rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() - return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} - except asyncio.TimeoutError: - _LOGGER.exception( - "Timed out running command: `%s`, after: %ss", - self._ping_cmd, - self._count + PING_TIMEOUT, - ) - if pinger: - with suppress(TypeError): - await pinger.kill() # type: ignore[func-returns-value] - del pinger - - return None - except AttributeError: - return None - - async def async_update(self) -> None: - """Retrieve the latest details from the host.""" - self.data = await self.async_ping() - self.is_alive = self.data is not None diff --git a/homeassistant/components/ping/const.py b/homeassistant/components/ping/const.py index 1a77c62fa5c..fd70a9340c2 100644 --- a/homeassistant/components/ping/const.py +++ b/homeassistant/components/ping/const.py @@ -16,5 +16,3 @@ PING_ATTEMPTS_COUNT = 3 DOMAIN = "ping" PLATFORMS = [Platform.BINARY_SENSOR] - -PING_PRIVS = "ping_privs" diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index f546bd6bacc..9a63a2f844d 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -22,10 +22,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency from homeassistant.util.process import kill_subprocess -from .const import DOMAIN, ICMP_TIMEOUT, PING_ATTEMPTS_COUNT, PING_PRIVS, PING_TIMEOUT +from . import PingDomainData +from .const import DOMAIN, ICMP_TIMEOUT, PING_ATTEMPTS_COUNT, PING_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -97,7 +98,9 @@ async def async_setup_scanner( ) -> bool: """Set up the Host objects and return the update function.""" - privileged = hass.data[DOMAIN][PING_PRIVS] + data: PingDomainData = hass.data[DOMAIN] + + privileged = data.privileged ip_to_dev_id = {ip: dev_id for (dev_id, ip) in config[CONF_HOSTS].items()} interval = config.get( CONF_SCAN_INTERVAL, @@ -117,7 +120,7 @@ async def async_setup_scanner( async def async_update(now: datetime) -> None: """Update all the hosts on every interval time.""" - results = await gather_with_concurrency( + results = await gather_with_limited_concurrency( CONCURRENT_PING_LIMIT, *(hass.async_add_executor_job(host.update) for host in hosts), ) diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py new file mode 100644 index 00000000000..da58858a801 --- /dev/null +++ b/homeassistant/components/ping/helpers.py @@ -0,0 +1,162 @@ +"""Ping classes shared between platforms.""" +import asyncio +from contextlib import suppress +import logging +import re +from typing import TYPE_CHECKING, Any + +from icmplib import NameLookupError, async_ping + +from homeassistant.core import HomeAssistant + +from .const import ICMP_TIMEOUT, PING_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + +PING_MATCHER = re.compile( + r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" +) + +PING_MATCHER_BUSYBOX = re.compile( + r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" +) + +WIN32_PING_MATCHER = re.compile(r"(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms") + + +class PingData: + """The base class for handling the data retrieval.""" + + data: dict[str, Any] | None = None + is_alive: bool = False + + def __init__(self, hass: HomeAssistant, host: str, count: int) -> None: + """Initialize the data object.""" + self.hass = hass + self._ip_address = host + self._count = count + + +class PingDataICMPLib(PingData): + """The Class for handling the data retrieval using icmplib.""" + + def __init__( + self, hass: HomeAssistant, host: str, count: int, privileged: bool | None + ) -> None: + """Initialize the data object.""" + super().__init__(hass, host, count) + self._privileged = privileged + + async def async_update(self) -> None: + """Retrieve the latest details from the host.""" + _LOGGER.debug("ping address: %s", self._ip_address) + try: + data = await async_ping( + self._ip_address, + count=self._count, + timeout=ICMP_TIMEOUT, + privileged=self._privileged, + ) + except NameLookupError: + self.is_alive = False + return + + self.is_alive = data.is_alive + if not self.is_alive: + self.data = None + return + + self.data = { + "min": data.min_rtt, + "max": data.max_rtt, + "avg": data.avg_rtt, + "mdev": "", + } + + +class PingDataSubProcess(PingData): + """The Class for handling the data retrieval using the ping binary.""" + + def __init__( + self, hass: HomeAssistant, host: str, count: int, privileged: bool | None + ) -> None: + """Initialize the data object.""" + super().__init__(hass, host, count) + self._ping_cmd = [ + "ping", + "-n", + "-q", + "-c", + str(self._count), + "-W1", + self._ip_address, + ] + + async def async_ping(self) -> dict[str, Any] | None: + """Send ICMP echo request and return details if success.""" + pinger = await asyncio.create_subprocess_exec( + *self._ping_cmd, + stdin=None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + close_fds=False, # required for posix_spawn + ) + try: + async with asyncio.timeout(self._count + PING_TIMEOUT): + out_data, out_error = await pinger.communicate() + + if out_data: + _LOGGER.debug( + "Output of command: `%s`, return code: %s:\n%s", + " ".join(self._ping_cmd), + pinger.returncode, + out_data, + ) + if out_error: + _LOGGER.debug( + "Error of command: `%s`, return code: %s:\n%s", + " ".join(self._ping_cmd), + pinger.returncode, + out_error, + ) + + if pinger.returncode and pinger.returncode > 1: + # returncode of 1 means the host is unreachable + _LOGGER.exception( + "Error running command: `%s`, return code: %s", + " ".join(self._ping_cmd), + pinger.returncode, + ) + + if "max/" not in str(out_data): + match = PING_MATCHER_BUSYBOX.search( + str(out_data).rsplit("\n", maxsplit=1)[-1] + ) + if TYPE_CHECKING: + assert match is not None + rtt_min, rtt_avg, rtt_max = match.groups() + return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": ""} + match = PING_MATCHER.search(str(out_data).rsplit("\n", maxsplit=1)[-1]) + if TYPE_CHECKING: + assert match is not None + rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() + return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} + except asyncio.TimeoutError: + _LOGGER.exception( + "Timed out running command: `%s`, after: %ss", + self._ping_cmd, + self._count + PING_TIMEOUT, + ) + if pinger: + with suppress(TypeError): + await pinger.kill() # type: ignore[func-returns-value] + del pinger + + return None + except AttributeError: + return None + + async def async_update(self) -> None: + """Retrieve the latest details from the host.""" + self.data = await self.async_ping() + self.is_alive = self.data is not None diff --git a/homeassistant/components/ping/manifest.json b/homeassistant/components/ping/manifest.json index 9290c9992eb..e27c3a239d0 100644 --- a/homeassistant/components/ping/manifest.json +++ b/homeassistant/components/ping/manifest.json @@ -1,7 +1,7 @@ { "domain": "ping", "name": "Ping (ICMP)", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/ping", "iot_class": "local_polling", "loggers": ["icmplib"], diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 7936cb6e6c3..30b59c73994 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -20,7 +20,9 @@ DEBOUNCE_TIMEOUT = 1 DISPATCHERS: Final = "dispatchers" GDM_DEBOUNCER: Final = "gdm_debouncer" GDM_SCANNER: Final = "gdm_scanner" -PLATFORMS = frozenset([Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR]) +PLATFORMS = frozenset( + [Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.UPDATE] +) PLATFORMS_COMPLETED: Final = "platforms_completed" PLAYER_SOURCE = "player_source" SERVERS: Final = "servers" diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 6cf94793173..a11d2d865c2 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,9 +8,9 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.3", + "PlexAPI==4.15.4", "plexauth==0.0.6", - "plexwebsocket==0.0.13" + "plexwebsocket==0.0.14" ], "zeroconf": ["_plexmediasvr._tcp.local."] } diff --git a/homeassistant/components/plex/update.py b/homeassistant/components/plex/update.py new file mode 100644 index 00000000000..e48c3a339d5 --- /dev/null +++ b/homeassistant/components/plex/update.py @@ -0,0 +1,76 @@ +"""Representation of Plex updates.""" +import logging +from typing import Any + +from plexapi.exceptions import PlexApiException +import plexapi.server +import requests.exceptions + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_SERVER_IDENTIFIER +from .helpers import get_plex_server + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Plex update entities from a config entry.""" + server_id = config_entry.data[CONF_SERVER_IDENTIFIER] + server = get_plex_server(hass, server_id) + plex_server = server.plex_server + can_update = await hass.async_add_executor_job(plex_server.canInstallUpdate) + async_add_entities([PlexUpdate(plex_server, can_update)], update_before_add=True) + + +class PlexUpdate(UpdateEntity): + """Representation of a Plex server update entity.""" + + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + _release_notes: str | None = None + + def __init__( + self, plex_server: plexapi.server.PlexServer, can_update: bool + ) -> None: + """Initialize the Update entity.""" + self.plex_server = plex_server + self._attr_name = f"Plex Media Server ({plex_server.friendlyName})" + self._attr_unique_id = plex_server.machineIdentifier + if can_update: + self._attr_supported_features |= UpdateEntityFeature.INSTALL + + def update(self) -> None: + """Update sync attributes.""" + self._attr_installed_version = self.plex_server.version + try: + if (release := self.plex_server.checkForUpdate()) is None: + return + except (requests.exceptions.RequestException, PlexApiException): + _LOGGER.debug("Polling update sensor failed, will try again") + return + self._attr_latest_version = release.version + if release.fixed: + self._release_notes = "\n".join( + f"* {line}" for line in release.fixed.split("\n") + ) + else: + self._release_notes = None + + def release_notes(self) -> str | None: + """Return release notes for the available upgrade.""" + return self._release_notes + + def install(self, version: str | None, backup: bool, **kwargs: Any) -> None: + """Install an update.""" + try: + self.plex_server.installUpdate() + except (requests.exceptions.RequestException, PlexApiException) as exc: + raise HomeAssistantError(str(exc)) from exc diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 610ffa34d7c..a33cef0e3a7 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -66,13 +66,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = presets - # Determine hvac modes and current hvac mode - self._attr_hvac_modes = [HVACMode.HEAT] - if self.coordinator.data.gateway["cooling_present"]: - self._attr_hvac_modes = [HVACMode.HEAT_COOL] - if self.device["available_schedules"] != ["None"]: - self._attr_hvac_modes.append(HVACMode.AUTO) - self._attr_min_temp = self.device["thermostat"]["lower_bound"] self._attr_max_temp = self.device["thermostat"]["upper_bound"] # Ensure we don't drop below 0.1 @@ -117,19 +110,21 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): return HVACMode.HEAT return HVACMode(mode) + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available HVACModes.""" + hvac_modes = [HVACMode.HEAT] + if self.coordinator.data.gateway["cooling_present"]: + hvac_modes = [HVACMode.HEAT_COOL] + + if self.device["available_schedules"] != ["None"]: + hvac_modes.append(HVACMode.AUTO) + + return hvac_modes + @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" - # When control_state is present, prefer this data - if (control_state := self.device.get("control_state")) == "cooling": - return HVACAction.COOLING - # Support preheating state as heating, - # until preheating is added as a separate state - if control_state in ["heating", "preheating"]: - return HVACAction.HEATING - if control_state == "off": - return HVACAction.IDLE - heater: str | None = self.coordinator.data.gateway["heater_id"] if heater: heater_data = self.coordinator.data.devices[heater] @@ -174,9 +169,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): raise HomeAssistantError("Unsupported hvac_mode") await self.coordinator.api.set_schedule_state( - self.device["location"], - self.device["last_used"], - "on" if hvac_mode == HVACMode.AUTO else "off", + self.device["location"], "on" if hvac_mode == HVACMode.AUTO else "off" ) @plugwise_command diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index c8c678d6aae..1155aaffdf8 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.33.0"], + "requirements": ["plugwise==0.33.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 82228ee94e7..5348a1dc484 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -65,7 +65,7 @@ "state": { "asleep": "Night", "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", - "home": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::home%]", + "home": "[%key:common::state::home%]", "no_frost": "Anti-frost", "vacation": "Vacation" } diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index 770f1e50edf..3c2a82dfb98 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pypoint"], "quality_scale": "gold", - "requirements": ["pypoint==2.3.0"] + "requirements": ["pypoint==2.3.2"] } diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index ed120562374..c61196d9931 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -29,8 +29,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="pH", - translation_key="ph", icon="mdi:pool", + device_class=SensorDeviceClass.PH, ), SensorEntityDescription( key="Battery", diff --git a/homeassistant/components/poolsense/strings.json b/homeassistant/components/poolsense/strings.json index 9ec67e223a1..02f186994e2 100644 --- a/homeassistant/components/poolsense/strings.json +++ b/homeassistant/components/poolsense/strings.json @@ -28,9 +28,6 @@ "chlorine": { "name": "Chlorine" }, - "ph": { - "name": "pH" - }, "last_seen": { "name": "Last seen" }, diff --git a/homeassistant/components/portlandgeneral/__init__.py b/homeassistant/components/portlandgeneral/__init__.py new file mode 100644 index 00000000000..67ab073a01d --- /dev/null +++ b/homeassistant/components/portlandgeneral/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Portland General Electric (PGE).""" diff --git a/homeassistant/components/portlandgeneral/manifest.json b/homeassistant/components/portlandgeneral/manifest.json new file mode 100644 index 00000000000..1f3b00b0992 --- /dev/null +++ b/homeassistant/components/portlandgeneral/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "portlandgeneral", + "name": "Portland General Electric (PGE)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json index 1b33c778843..d96f76d25a4 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/profiler", "quality_scale": "internal", - "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.3", "objgraph==3.5.0"] + "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.4", "objgraph==3.5.0"] } diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index ea975529b01..ac2b704b012 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -21,7 +21,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_COUNTRY): vol.In(COUNTRY.keys()), + vol.Required(CONF_COUNTRY): selector.CountrySelector( + selector.CountrySelectorConfig(countries=list(COUNTRY)) + ), } ) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index a4520435161..23a8fc3bf64 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -70,25 +70,25 @@ def async_setup_proximity_component( ignored_zones: list[str] = config[CONF_IGNORED_ZONES] proximity_devices: list[str] = config[CONF_DEVICES] tolerance: int = config[CONF_TOLERANCE] - proximity_zone = name + proximity_zone = config[CONF_ZONE] unit_of_measurement: str = config.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) - zone_id = f"zone.{config[CONF_ZONE]}" + zone_friendly_name = name proximity = Proximity( hass, - proximity_zone, + zone_friendly_name, DEFAULT_DIST_TO_ZONE, DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST, ignored_zones, proximity_devices, tolerance, - zone_id, + proximity_zone, unit_of_measurement, ) - proximity.entity_id = f"{DOMAIN}.{proximity_zone}" + proximity.entity_id = f"{DOMAIN}.{zone_friendly_name}" proximity.async_write_ha_state() @@ -171,7 +171,7 @@ class Proximity(Entity): devices_to_calculate = False devices_in_zone = "" - zone_state = self.hass.states.get(self.proximity_zone) + zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") proximity_latitude = ( zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None ) @@ -189,7 +189,7 @@ class Proximity(Entity): devices_to_calculate = True # Check the location of all devices. - if (device_state.state).lower() == (self.friendly_name).lower(): + if (device_state.state).lower() == (self.proximity_zone).lower(): device_friendly = device_state.name if devices_in_zone != "": devices_in_zone = f"{devices_in_zone}, " diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index b5b25a66342..59798e38957 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==10.0.1"] + "requirements": ["Pillow==10.1.0"] } diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json index ff505010713..b082e088ba2 100644 --- a/homeassistant/components/purpleair/strings.json +++ b/homeassistant/components/purpleair/strings.json @@ -56,8 +56,8 @@ "title": "Add Sensor", "description": "[%key:component::purpleair::config::step::by_coordinates::description%]", "data": { - "latitude": "[%key:component::purpleair::config::step::by_coordinates::data::latitude%]", - "longitude": "[%key:component::purpleair::config::step::by_coordinates::data::longitude%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", "distance": "[%key:component::purpleair::config::step::by_coordinates::data::distance%]" }, "data_description": { diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index b78f49b74f9..787e59db3db 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==1.0.0"] + "requirements": ["pvo==2.0.0"] } diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index 80ed6164e74..bd034053a34 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,8 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==6.2"] + "requirements": [ + "RestrictedPython==6.2;python_version<'3.12'", + "RestrictedPython==7.0a1.dev0;python_version>='3.12'" + ] } diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 53e8d4b9660..fd9577f5c73 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN +from .coordinator import QBittorrentDataCoordinator from .helpers import setup_client PLATFORMS = [Platform.SENSOR] @@ -27,7 +28,7 @@ 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( + client = await hass.async_add_executor_job( setup_client, entry.data[CONF_URL], entry.data[CONF_USERNAME], @@ -38,7 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady("Invalid credentials") from err except RequestException as err: raise ConfigEntryNotReady("Failed to connect") from err + coordinator = QBittorrentDataCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py index 54c47c53895..54215fb4563 100644 --- a/homeassistant/components/qbittorrent/config_flow.py +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -9,13 +9,7 @@ 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.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN @@ -61,16 +55,3 @@ class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): 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/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py new file mode 100644 index 00000000000..8363a764d0a --- /dev/null +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -0,0 +1,38 @@ +"""The QBittorrent coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from qbittorrent import Client +from qbittorrent.client import LoginRequired + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """QBittorrent update coordinator.""" + + def __init__(self, hass: HomeAssistant, client: Client) -> None: + """Initialize coordinator.""" + self.client = client + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self) -> dict[str, Any]: + try: + return await self.hass.async_add_executor_job(self.client.sync_main_data) + except LoginRequired as exc: + raise ConfigEntryError("Invalid authentication") from exc diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 0d5dc160a11..e2feee1e60c 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -1,35 +1,26 @@ """Support for monitoring the qBittorrent API.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging - -from qbittorrent.client import Client, LoginRequired -from requests.exceptions import RequestException -import voluptuous as vol +from typing import Any from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_URL, - CONF_USERNAME, - STATE_IDLE, - UnitOfDataRate, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_IDLE, UnitOfDataRate +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN +from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -37,79 +28,30 @@ SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key=SENSOR_TYPE_CURRENT_STATUS, - name="Status", - ), - SensorEntityDescription( - key=SENSOR_TYPE_DOWNLOAD_SPEED, - name="Down Speed", - icon="mdi:cloud-download", - device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key=SENSOR_TYPE_UPLOAD_SPEED, - name="Up Speed", - icon="mdi:cloud-upload", - device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - ), -) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) +@dataclass +class QBittorrentMixin: + """Mixin for required keys.""" + + value_fn: Callable[[dict[str, Any]], StateType] -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """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, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.11.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "qBittorrent", - }, - ) +@dataclass +class QBittorrentSensorEntityDescription(SensorEntityDescription, QBittorrentMixin): + """Describes QBittorrent sensor entity.""" -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, config_entry) - for description in SENSOR_TYPES - ] - async_add_entites(entities, True) +def _get_qbittorrent_state(data: dict[str, Any]) -> str: + download = data["server_state"]["dl_info_speed"] + upload = data["server_state"]["up_info_speed"] + + if upload > 0 and download > 0: + return "up_down" + if upload > 0 and download == 0: + return "seeding" + if upload == 0 and download > 0: + return "downloading" + return STATE_IDLE def format_speed(speed): @@ -118,54 +60,66 @@ def format_speed(speed): return round(kb_spd, 2 if kb_spd < 0.1 else 1) -class QBittorrentSensor(SensorEntity): - """Representation of an qBittorrent sensor.""" +SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_CURRENT_STATUS, + name="Status", + value_fn=_get_qbittorrent_state, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_DOWNLOAD_SPEED, + name="Down Speed", + icon="mdi:cloud-download", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: format_speed(data["server_state"]["dl_info_speed"]), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_UPLOAD_SPEED, + name="Up Speed", + icon="mdi:cloud-upload", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: format_speed(data["server_state"]["up_info_speed"]), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entites: AddEntitiesCallback, +) -> None: + """Set up qBittorrent sensor entries.""" + coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [ + QBittorrentSensor(description, coordinator, config_entry) + for description in SENSOR_TYPES + ] + async_add_entites(entities) + + +class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEntity): + """Representation of a qBittorrent sensor.""" + + entity_description: QBittorrentSensorEntityDescription def __init__( self, - description: SensorEntityDescription, - qbittorrent_client: Client, + description: QBittorrentSensorEntityDescription, + coordinator: QBittorrentDataCoordinator, config_entry: ConfigEntry, ) -> None: """Initialize the qBittorrent sensor.""" + super().__init__(coordinator) self.entity_description = description - self.client = qbittorrent_client - 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: - """Get the latest data from qBittorrent and updates the state.""" - try: - data = self.client.sync_main_data() - self._attr_available = True - except RequestException: - _LOGGER.error("Connection lost") - self._attr_available = False - return - except LoginRequired: - _LOGGER.error("Invalid authentication") - return - - if data is None: - return - - download = data["server_state"]["dl_info_speed"] - upload = data["server_state"]["up_info_speed"] - - sensor_type = self.entity_description.key - if sensor_type == SENSOR_TYPE_CURRENT_STATUS: - if upload > 0 and download > 0: - self._attr_native_value = "up_down" - elif upload > 0 and download == 0: - self._attr_native_value = "seeding" - elif upload == 0 and download > 0: - self._attr_native_value = "downloading" - else: - self._attr_native_value = STATE_IDLE - - elif sensor_type == SENSOR_TYPE_DOWNLOAD_SPEED: - self._attr_native_value = format_speed(download) - elif sensor_type == SENSOR_TYPE_UPLOAD_SPEED: - self._attr_native_value = format_speed(upload) + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/qingping/strings.json b/homeassistant/components/qingping/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/qingping/strings.json +++ b/homeassistant/components/qingping/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 4bf410c7f87..dfd03deca16 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -351,6 +351,7 @@ class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): self._attr_unique_id = f"{self._attr_unique_id}_{monitor_device}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, + serial_number=unique_id, name=self.device_name, model=self.coordinator.data["system_stats"]["system"]["model"], sw_version=self.coordinator.data["system_stats"]["firmware"]["version"], diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index f1f40dd8973..23bd1d050a1 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==10.0.1", "pyzbar==0.1.7"] + "requirements": ["Pillow==10.1.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index c7f31a999e7..39258e2f787 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -25,6 +25,7 @@ from .coordinator import ( DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, + QueueDataUpdateCoordinator, RadarrDataUpdateCoordinator, StatusDataUpdateCoordinator, T, @@ -45,10 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { - "status": StatusDataUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), + "queue": QueueDataUpdateCoordinator(hass, host_configuration, radarr), + "status": StatusDataUpdateCoordinator(hass, host_configuration, radarr), } for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/radarr/const.py b/homeassistant/components/radarr/const.py index b77e134ca34..37388dd51ef 100644 --- a/homeassistant/components/radarr/const.py +++ b/homeassistant/components/radarr/const.py @@ -5,6 +5,7 @@ from typing import Final DOMAIN: Final = "radarr" # Defaults +DEFAULT_MAX_RECORDS = 20 DEFAULT_NAME = "Radarr" DEFAULT_URL = "http://127.0.0.1:7878" diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index c318d662028..bd41810bfb8 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import timedelta -from typing import Generic, TypeVar +from typing import Generic, TypeVar, cast from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions from aiopyarr.models.host_configuration import PyArrHostConfiguration @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER +from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int) @@ -90,7 +90,14 @@ class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]): async def _fetch_data(self) -> int: """Fetch the movies data.""" - movies = await self.api_client.async_get_movies() - if isinstance(movies, RadarrMovie): - return 1 - return len(movies) + return len(cast(list[RadarrMovie], await self.api_client.async_get_movies())) + + +class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): + """Queue update coordinator.""" + + async def _fetch_data(self) -> int: + """Fetch the movies in queue.""" + return ( + await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) + ).totalRecords diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 803b6de44a4..ab4315b269a 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation @@ -82,6 +83,15 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { entity_registry_enabled_default=False, value_fn=lambda data, _: data, ), + "queue": RadarrSensorEntityDescription[int]( + key="queue", + translation_key="queue", + native_unit_of_measurement="Movies", + icon="mdi:download", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data, _: data, + ), "status": RadarrSensorEntityDescription[SystemStatus]( key="start_time", translation_key="start_time", diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index 5cd7bcfc449..ec1baf6ffd8 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -45,6 +45,9 @@ "movies": { "name": "Movies" }, + "queue": { + "name": "Queue" + }, "start_time": { "name": "Start time" } diff --git a/homeassistant/components/random/__init__.py b/homeassistant/components/random/__init__.py index 01bde80b0c3..89a772529bd 100644 --- a/homeassistant/components/random/__init__.py +++ b/homeassistant/components/random/__init__.py @@ -1 +1,24 @@ """The random component.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + await hass.config_entries.async_forward_entry_setups( + entry, (entry.options["entity_type"],) + ) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms( + entry, (entry.options["entity_type"],) + ) diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 5e688162124..9ada2ecd621 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -1,7 +1,9 @@ """Support for showing random states.""" from __future__ import annotations +from collections.abc import Mapping from random import getrandbits +from typing import Any import voluptuous as vol @@ -10,13 +12,14 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -DEFAULT_NAME = "Random Binary Sensor" +DEFAULT_NAME = "Random binary sensor" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -33,20 +36,32 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Random binary sensor.""" - name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) - async_add_entities([RandomSensor(name, device_class)], True) + async_add_entities([RandomBinarySensor(config)], True) -class RandomSensor(BinarySensorEntity): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + async_add_entities( + [RandomBinarySensor(config_entry.options, config_entry.entry_id)], True + ) + + +class RandomBinarySensor(BinarySensorEntity): """Representation of a Random binary sensor.""" - def __init__(self, name, device_class): + _state: bool | None = None + + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" - self._name = name - self._device_class = device_class - self._state = None + self._name = config.get(CONF_NAME) + self._device_class = config.get(CONF_DEVICE_CLASS) + if entry_id: + self._attr_unique_id = entry_id @property def name(self): diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py new file mode 100644 index 00000000000..96dde9c8742 --- /dev/null +++ b/homeassistant/components/random/config_flow.py @@ -0,0 +1,186 @@ +"""Config flow for Random helper.""" +from collections.abc import Callable, Coroutine, Mapping +from enum import StrEnum +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import DEVICE_CLASS_UNITS, SensorDeviceClass +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_MAXIMUM, + CONF_MINIMUM, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + Platform, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, + SchemaFlowMenuStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import DOMAIN +from .sensor import DEFAULT_MAX, DEFAULT_MIN + + +class _FlowType(StrEnum): + CONFIG = "config" + OPTION = "option" + + +def _generate_schema(domain: str, flow_type: _FlowType) -> vol.Schema: + """Generate schema.""" + schema: dict[vol.Marker, Any] = {} + + if flow_type == _FlowType.CONFIG: + schema[vol.Required(CONF_NAME)] = TextSelector() + + if domain == Platform.BINARY_SENSOR: + schema[vol.Optional(CONF_DEVICE_CLASS)] = SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in BinarySensorDeviceClass], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="binary_sensor_device_class", + ), + ) + + if domain == Platform.SENSOR: + schema.update( + { + vol.Optional(CONF_MINIMUM, default=DEFAULT_MIN): cv.positive_int, + vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAX): cv.positive_int, + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="sensor_device_class", + ), + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + SelectSelectorConfig( + options=[ + str(unit) + for units in DEVICE_CLASS_UNITS.values() + for unit in units + if unit is not None + ], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="sensor_unit_of_measurement", + custom_value=True, + ), + ), + } + ) + + return vol.Schema(schema) + + +async def choose_options_step(options: dict[str, Any]) -> str: + """Return next step_id for options flow according to template_type.""" + return cast(str, options["entity_type"]) + + +def _validate_unit(options: dict[str, Any]) -> None: + """Validate unit of measurement.""" + if ( + (device_class := options.get(CONF_DEVICE_CLASS)) + and (units := DEVICE_CLASS_UNITS.get(device_class)) + and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units + ): + sorted_units = sorted( + [f"'{str(unit)}'" if unit else "no unit of measurement" for unit in units], + key=str.casefold, + ) + if len(sorted_units) == 1: + units_string = sorted_units[0] + else: + units_string = f"one of {', '.join(sorted_units)}" + + raise vol.Invalid( + f"'{unit}' is not a valid unit for device class '{device_class}'; " + f"expected {units_string}" + ) + + +def validate_user_input( + template_type: str, +) -> Callable[ + [SchemaCommonFlowHandler, dict[str, Any]], + Coroutine[Any, Any, dict[str, Any]], +]: + """Do post validation of user input. + + For sensors: Validate unit of measurement. + """ + + async def _validate_user_input( + _: SchemaCommonFlowHandler, + user_input: dict[str, Any], + ) -> dict[str, Any]: + """Add template type to user input.""" + if template_type == Platform.SENSOR: + _validate_unit(user_input) + return {"entity_type": template_type} | user_input + + return _validate_user_input + + +RANDOM_TYPES = [ + Platform.BINARY_SENSOR.value, + Platform.SENSOR.value, +] + +CONFIG_FLOW = { + "user": SchemaFlowMenuStep(RANDOM_TYPES), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.BINARY_SENSOR, _FlowType.CONFIG), + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.SENSOR, _FlowType.CONFIG), + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(next_step=choose_options_step), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.BINARY_SENSOR, _FlowType.OPTION), + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.SENSOR, _FlowType.OPTION), + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + + +class RandomConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle config flow for random helper.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + @callback + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) diff --git a/homeassistant/components/random/const.py b/homeassistant/components/random/const.py new file mode 100644 index 00000000000..df6a18f8d11 --- /dev/null +++ b/homeassistant/components/random/const.py @@ -0,0 +1,5 @@ +"""Constants for random helper.""" +DOMAIN = "random" + +DEFAULT_MIN = 0 +DEFAULT_MAX = 20 diff --git a/homeassistant/components/random/manifest.json b/homeassistant/components/random/manifest.json index 164445fd8ed..36396f0a1f6 100644 --- a/homeassistant/components/random/manifest.json +++ b/homeassistant/components/random/manifest.json @@ -2,7 +2,9 @@ "domain": "random", "name": "Random", "codeowners": ["@fabaff"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/random", - "iot_class": "local_polling", + "integration_type": "helper", + "iot_class": "calculated", "quality_scale": "internal" } diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index d4db30fd61e..8e77f026253 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -1,12 +1,16 @@ """Support for showing random numbers.""" from __future__ import annotations +from collections.abc import Mapping from random import randrange +from typing import Any import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME, @@ -17,12 +21,12 @@ 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_MAX, DEFAULT_MIN + ATTR_MAXIMUM = "maximum" ATTR_MINIMUM = "minimum" -DEFAULT_NAME = "Random Sensor" -DEFAULT_MIN = 0 -DEFAULT_MAX = 20 +DEFAULT_NAME = "Random sensor" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -42,26 +46,37 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Random number sensor.""" - name = config.get(CONF_NAME) - minimum = config.get(CONF_MINIMUM) - maximum = config.get(CONF_MAXIMUM) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - async_add_entities([RandomSensor(name, minimum, maximum, unit)], True) + async_add_entities([RandomSensor(config)], True) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + + async_add_entities( + [RandomSensor(config_entry.options, config_entry.entry_id)], True + ) class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" _attr_icon = "mdi:hanger" + _state: int | None = None - def __init__(self, name, minimum, maximum, unit_of_measurement): + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" - self._name = name - self._minimum = minimum - self._maximum = maximum - self._unit_of_measurement = unit_of_measurement - self._state = None + self._name = config.get(CONF_NAME) + self._minimum = config.get(CONF_MINIMUM, DEFAULT_MIN) + self._maximum = config.get(CONF_MAXIMUM, DEFAULT_MAX) + self._unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + if entry_id: + self._attr_unique_id = entry_id @property def name(self): diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json new file mode 100644 index 00000000000..164f184ae88 --- /dev/null +++ b/homeassistant/components/random/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "step": { + "binary_sensor": { + "data": { + "device_class": "[%key:component::random::config::step::sensor::data::device_class%]", + "name": "[%key:common::config_flow::data::name%]" + }, + "title": "Random binary sensor" + }, + "sensor": { + "data": { + "device_class": "Device class", + "name": "[%key:common::config_flow::data::name%]", + "minimum": "Minimum", + "maximum": "Maximum", + "unit_of_measurement": "Unit of measurement" + }, + "title": "Random sensor" + }, + "user": { + "description": "This helper allow you to create a helper that emits a random value.", + "menu_options": { + "binary_sensor": "Random binary sensor", + "sensor": "Random sensor" + }, + "title": "Random helper" + } + } + }, + "options": { + "step": { + "binary_sensor": { + "title": "[%key:component::random::config::step::binary_sensor::title%]", + "description": "This helper does not have any options." + }, + "sensor": { + "data": { + "device_class": "[%key:component::random::config::step::sensor::data::device_class%]", + "minimum": "[%key:component::random::config::step::sensor::data::minimum%]", + "maximum": "[%key:component::random::config::step::sensor::data::maximum%]", + "unit_of_measurement": "[%key:component::random::config::step::sensor::data::unit_of_measurement%]" + }, + "title": "[%key:component::random::config::step::sensor::title%]" + } + } + } +} diff --git a/homeassistant/components/rapt_ble/strings.json b/homeassistant/components/rapt_ble/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/rapt_ble/strings.json +++ b/homeassistant/components/rapt_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 1c00149192f..c82d431a8fa 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -6,7 +6,12 @@ from typing import Any import voluptuous as vol -from homeassistant.const import CONF_EXCLUDE, EVENT_STATE_CHANGED +from homeassistant.const import ( + CONF_EXCLUDE, + EVENT_RECORDER_5MIN_STATISTICS_GENERATED, # noqa: F401 + EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, # noqa: F401 + EVENT_STATE_CHANGED, +) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( @@ -25,8 +30,6 @@ from .const import ( # noqa: F401 CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, DOMAIN, - EVENT_RECORDER_5MIN_STATISTICS_GENERATED, - EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, INTEGRATION_PLATFORM_COMPILE_STATISTICS, INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD, SQLITE_URL_PREFIX, diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 7389cbf8ddf..66d46c0c20e 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -2,7 +2,13 @@ from enum import StrEnum -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_RESTORED, + ATTR_SUPPORTED_FEATURES, + EVENT_RECORDER_5MIN_STATISTICS_GENERATED, # noqa: F401 + EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, # noqa: F401 +) from homeassistant.helpers.json import JSON_DUMP # noqa: F401 DATA_INSTANCE = "recorder_instance" @@ -13,9 +19,6 @@ MYSQLDB_URL_PREFIX = "mysql://" MYSQLDB_PYMYSQL_URL_PREFIX = "mysql+pymysql://" DOMAIN = "recorder" -EVENT_RECORDER_5MIN_STATISTICS_GENERATED = "recorder_5min_statistics_generated" -EVENT_RECORDER_HOURLY_STATISTICS_GENERATED = "recorder_hourly_statistics_generated" - CONF_DB_INTEGRITY_CHECK = "db_integrity_check" MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 @@ -30,6 +33,12 @@ QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY = 0.65 # have upgraded their sqlite version SQLITE_MAX_BIND_VARS = 998 +# The maximum bind vars for sqlite 3.32.0 and above, but +# capped at 4000 to avoid performance issues +SQLITE_MODERN_MAX_BIND_VARS = 4000 + +DEFAULT_MAX_BIND_VARS = 4000 + DB_WORKER_PREFIX = "DbWorker" ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 0e926ad2a22..a8746a0a807 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -55,6 +55,7 @@ from .const import ( MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY, + SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, STATES_META_SCHEMA_VERSION, STATISTICS_ROWS_SCHEMA_VERSION, @@ -242,6 +243,13 @@ class Recorder(threading.Thread): self._dialect_name: SupportedDialect | None = None self.enabled = True + # For safety we default to the lowest value for max_bind_vars + # of all the DB types (SQLITE_MAX_BIND_VARS). + # + # We update the value once we connect to the DB + # and determine what is actually supported. + self.max_bind_vars = SQLITE_MAX_BIND_VARS + @property def backlog(self) -> int: """Return the number of items in the recorder backlog.""" @@ -1351,6 +1359,7 @@ class Recorder(threading.Thread): not self._completed_first_database_setup, ): self.database_engine = database_engine + self.max_bind_vars = database_engine.max_bind_vars self._completed_first_database_setup = True def _setup_connection(self) -> None: diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 17e34af1e11..06c8cf68903 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 41 +SCHEMA_VERSION = 42 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index f40797fe38c..f0e91071ea0 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,8 +7,8 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.21", - "fnv-hash-fast==0.4.1", + "SQLAlchemy==2.0.22", + "fnv-hash-fast==0.5.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index f07e91ddaea..8808ed2fd2b 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -68,13 +68,20 @@ from .db_schema import ( StatisticsShortTerm, ) from .models import process_timestamp +from .models.time import datetime_to_timestamp_or_none from .queries import ( batch_cleanup_entity_ids, + delete_duplicate_short_term_statistics_row, + delete_duplicate_statistics_row, find_entity_ids_to_migrate, find_event_type_to_migrate, find_events_context_ids_to_migrate, find_states_context_ids_to_migrate, + find_unmigrated_short_term_statistics_rows, + find_unmigrated_statistics_rows, has_used_states_event_ids, + migrate_single_short_term_statistics_row_to_timestamp, + migrate_single_statistics_row_to_timestamp, ) from .statistics import get_start_time from .tasks import ( @@ -950,26 +957,9 @@ def _apply_update( # noqa: C901 "statistics_short_term", "ix_statistics_short_term_statistic_id_start_ts", ) - try: - _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) - except IntegrityError as ex: - _LOGGER.error( - "Statistics table contains duplicate entries: %s; " - "Cleaning up duplicates and trying again; " - "This will take a while; " - "Please be patient!", - ex, - ) - # There may be duplicated statistics entries, delete duplicates - # and try again - with session_scope(session=session_maker()) as session: - delete_statistics_duplicates(instance, hass, session) - _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) - # Log at error level to ensure the user sees this message in the log - # since we logged the error above. - _LOGGER.error( - "Statistics migration successfully recovered after statistics table duplicate cleanup" - ) + _migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, session_maker, engine + ) elif new_version == 35: # Migration is done in two steps to ensure we can start using # the new columns before we wipe the old ones. @@ -1060,10 +1050,55 @@ def _apply_update( # noqa: C901 elif new_version == 41: _create_index(session_maker, "event_types", "ix_event_types_event_type") _create_index(session_maker, "states_meta", "ix_states_meta_entity_id") + elif new_version == 42: + # If the user had a previously failed migration, or they + # downgraded from 2023.3.x to an older version we will have + # unmigrated statistics columns so we want to clean this up + # one last time since compiling the statistics will be slow + # or fail if we have unmigrated statistics. + _migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, session_maker, engine + ) else: raise ValueError(f"No schema migration defined for version {new_version}") +def _migrate_statistics_columns_to_timestamp_removing_duplicates( + hass: HomeAssistant, + instance: Recorder, + session_maker: Callable[[], Session], + engine: Engine, +) -> None: + """Migrate statistics columns to timestamp or cleanup duplicates.""" + try: + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) + except IntegrityError as ex: + _LOGGER.error( + "Statistics table contains duplicate entries: %s; " + "Cleaning up duplicates and trying again; " + "This will take a while; " + "Please be patient!", + ex, + ) + # There may be duplicated statistics entries, delete duplicates + # and try again + with session_scope(session=session_maker()) as session: + delete_statistics_duplicates(instance, hass, session) + try: + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) + except IntegrityError: + _LOGGER.warning( + "Statistics table still contains duplicate entries after cleanup; " + "Falling back to a one by one migration" + ) + _migrate_statistics_columns_to_timestamp_one_by_one(instance, session_maker) + # Log at error level to ensure the user sees this message in the log + # since we logged the error above. + _LOGGER.error( + "Statistics migration successfully recovered after statistics table duplicate cleanup" + ) + + def _correct_table_character_set_and_collation( table: str, session_maker: Callable[[], Session], @@ -1269,6 +1304,59 @@ def _migrate_columns_to_timestamp( ) +@database_job_retry_wrapper("Migrate statistics columns to timestamp one by one", 3) +def _migrate_statistics_columns_to_timestamp_one_by_one( + instance: Recorder, session_maker: Callable[[], Session] +) -> None: + """Migrate statistics columns to use timestamp on by one. + + If something manually inserted data into the statistics table + in the past it may have inserted duplicate rows. + + Before we had the unique index on (statistic_id, start) this + the data could have been inserted without any errors and we + could end up with duplicate rows that go undetected (even by + our current duplicate cleanup code) until we try to migrate the + data to use timestamps. + + This will migrate the data one by one to ensure we do not hit any + duplicate rows, and remove the duplicate rows as they are found. + """ + for find_func, migrate_func, delete_func in ( + ( + find_unmigrated_statistics_rows, + migrate_single_statistics_row_to_timestamp, + delete_duplicate_statistics_row, + ), + ( + find_unmigrated_short_term_statistics_rows, + migrate_single_short_term_statistics_row_to_timestamp, + delete_duplicate_short_term_statistics_row, + ), + ): + with session_scope(session=session_maker()) as session: + while stats := session.execute(find_func(instance.max_bind_vars)).all(): + for statistic_id, start, created, last_reset in stats: + start_ts = datetime_to_timestamp_or_none(process_timestamp(start)) + created_ts = datetime_to_timestamp_or_none( + process_timestamp(created) + ) + last_reset_ts = datetime_to_timestamp_or_none( + process_timestamp(last_reset) + ) + try: + session.execute( + migrate_func( + statistic_id, start_ts, created_ts, last_reset_ts + ) + ) + except IntegrityError: + # This can happen if we have duplicate rows + # in the statistics table. + session.execute(delete_func(statistic_id)) + session.commit() + + @database_job_retry_wrapper("Migrate statistics columns to timestamp", 3) def _migrate_statistics_columns_to_timestamp( instance: Recorder, session_maker: Callable[[], Session], engine: Engine @@ -1292,7 +1380,7 @@ def _migrate_statistics_columns_to_timestamp( f"created_ts=strftime('%s',created) + " "cast(substr(created,-7) AS FLOAT), " f"last_reset_ts=strftime('%s',last_reset) + " - "cast(substr(last_reset,-7) AS FLOAT);" + "cast(substr(last_reset,-7) AS FLOAT) where start_ts is NULL;" ) ) elif engine.dialect.name == SupportedDialect.MYSQL: @@ -1366,7 +1454,9 @@ def migrate_states_context_ids(instance: Recorder) -> bool: session_maker = instance.get_session _LOGGER.debug("Migrating states context_ids to binary format") with session_scope(session=session_maker()) as session: - if states := session.execute(find_states_context_ids_to_migrate()).all(): + if states := session.execute( + find_states_context_ids_to_migrate(instance.max_bind_vars) + ).all(): session.execute( update(States), [ @@ -1401,7 +1491,9 @@ def migrate_events_context_ids(instance: Recorder) -> bool: session_maker = instance.get_session _LOGGER.debug("Migrating context_ids to binary format") with session_scope(session=session_maker()) as session: - if events := session.execute(find_events_context_ids_to_migrate()).all(): + if events := session.execute( + find_events_context_ids_to_migrate(instance.max_bind_vars) + ).all(): session.execute( update(Events), [ @@ -1436,7 +1528,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: _LOGGER.debug("Migrating event_types") event_type_manager = instance.event_type_manager with session_scope(session=session_maker()) as session: - if events := session.execute(find_event_type_to_migrate()).all(): + if events := session.execute( + find_event_type_to_migrate(instance.max_bind_vars) + ).all(): event_types = {event_type for _, event_type in events} if None in event_types: # event_type should never be None but we need to be defensive @@ -1505,7 +1599,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: _LOGGER.debug("Migrating entity_ids") states_meta_manager = instance.states_meta_manager with session_scope(session=instance.get_session()) as session: - if states := session.execute(find_entity_ids_to_migrate()).all(): + if states := session.execute( + find_entity_ids_to_migrate(instance.max_bind_vars) + ).all(): entity_ids = {entity_id for _, entity_id in states} if None in entity_ids: # entity_id should never be None but we need to be defensive diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index e39f05cd9c5..a8c23d20061 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -18,6 +18,7 @@ class DatabaseEngine: dialect: SupportedDialect optimizer: DatabaseOptimizer + max_bind_vars: int version: AwesomeVersion | None diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 9dff59d1f59..8bc6584c5a1 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -12,7 +12,6 @@ from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util -from .const import SQLITE_MAX_BIND_VARS from .db_schema import Events, States, StatesMeta from .models import DatabaseEngine from .queries import ( @@ -72,7 +71,7 @@ def purge_old_data( purge_before.isoformat(sep=" ", timespec="seconds"), ) with session_scope(session=instance.get_session()) as session: - # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states or events record + # Purge a max of max_bind_vars, based on the oldest states or events record has_more_to_purge = False if instance.use_legacy_events_index and _purging_legacy_format(session): _LOGGER.debug( @@ -93,9 +92,11 @@ def purge_old_data( instance, session, events_batch_size, purge_before ) - statistics_runs = _select_statistics_runs_to_purge(session, purge_before) + statistics_runs = _select_statistics_runs_to_purge( + session, purge_before, instance.max_bind_vars + ) short_term_statistics = _select_short_term_statistics_to_purge( - session, purge_before + session, purge_before, instance.max_bind_vars ) if statistics_runs: _purge_statistics_runs(session, statistics_runs) @@ -141,7 +142,7 @@ def _purge_legacy_format( attributes_ids, data_ids, ) = _select_legacy_event_state_and_attributes_and_data_ids_to_purge( - session, purge_before + session, purge_before, instance.max_bind_vars ) _purge_state_ids(instance, session, state_ids) _purge_unused_attributes_ids(instance, session, attributes_ids) @@ -157,7 +158,7 @@ def _purge_legacy_format( detached_state_ids, detached_attributes_ids, ) = _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( - session, purge_before + session, purge_before, instance.max_bind_vars ) _purge_state_ids(instance, session, detached_state_ids) _purge_unused_attributes_ids(instance, session, detached_attributes_ids) @@ -187,11 +188,12 @@ def _purge_states_and_attributes_ids( # There are more states relative to attributes_ids so # we purge enough state_ids to try to generate a full # size batch of attributes_ids that will be around the size - # SQLITE_MAX_BIND_VARS + # max_bind_vars attributes_ids_batch: set[int] = set() + max_bind_vars = instance.max_bind_vars for _ in range(states_batch_size): state_ids, attributes_ids = _select_state_attributes_ids_to_purge( - session, purge_before + session, purge_before, max_bind_vars ) if not state_ids: has_remaining_state_ids_to_purge = False @@ -221,10 +223,13 @@ def _purge_events_and_data_ids( # There are more events relative to data_ids so # we purge enough event_ids to try to generate a full # size batch of data_ids that will be around the size - # SQLITE_MAX_BIND_VARS + # max_bind_vars data_ids_batch: set[int] = set() + max_bind_vars = instance.max_bind_vars for _ in range(events_batch_size): - event_ids, data_ids = _select_event_data_ids_to_purge(session, purge_before) + event_ids, data_ids = _select_event_data_ids_to_purge( + session, purge_before, max_bind_vars + ) if not event_ids: has_remaining_event_ids_to_purge = False break @@ -240,13 +245,13 @@ def _purge_events_and_data_ids( def _select_state_attributes_ids_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> tuple[set[int], set[int]]: """Return sets of state and attribute ids to purge.""" state_ids = set() attributes_ids = set() for state_id, attributes_id in session.execute( - find_states_to_purge(dt_util.utc_to_timestamp(purge_before)) + find_states_to_purge(dt_util.utc_to_timestamp(purge_before), max_bind_vars) ).all(): state_ids.add(state_id) if attributes_id: @@ -260,13 +265,13 @@ def _select_state_attributes_ids_to_purge( def _select_event_data_ids_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> tuple[set[int], set[int]]: """Return sets of event and data ids to purge.""" event_ids = set() data_ids = set() for event_id, data_id in session.execute( - find_events_to_purge(dt_util.utc_to_timestamp(purge_before)) + find_events_to_purge(dt_util.utc_to_timestamp(purge_before), max_bind_vars) ).all(): event_ids.add(event_id) if data_id: @@ -323,7 +328,7 @@ def _select_unused_attributes_ids( # # We used to generate a query based on how many attribute_ids to find but # that meant sqlalchemy Transparent SQL Compilation Caching was working against - # us by cached up to SQLITE_MAX_BIND_VARS different statements which could be + # us by cached up to max_bind_vars different statements which could be # up to 500MB for large database due to the complexity of the ORM objects. # # We now break the query into groups of 100 and use a lambda_stmt to ensure @@ -405,13 +410,15 @@ def _purge_unused_data_ids( def _select_statistics_runs_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> list[int]: """Return a list of statistic runs to purge. Takes care to keep the newest run. """ - statistic_runs = session.execute(find_statistics_runs_to_purge(purge_before)).all() + statistic_runs = session.execute( + find_statistics_runs_to_purge(purge_before, max_bind_vars) + ).all() statistic_runs_list = [run_id for (run_id,) in statistic_runs] # Exclude the newest statistics run if ( @@ -424,18 +431,18 @@ def _select_statistics_runs_to_purge( def _select_short_term_statistics_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> list[int]: """Return a list of short term statistics to purge.""" statistics = session.execute( - find_short_term_statistics_to_purge(purge_before) + find_short_term_statistics_to_purge(purge_before, max_bind_vars) ).all() _LOGGER.debug("Selected %s short term statistics to remove", len(statistics)) return [statistic_id for (statistic_id,) in statistics] def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> tuple[set[int], set[int]]: """Return a list of state, and attribute ids to purge. @@ -445,7 +452,7 @@ def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( """ states = session.execute( find_legacy_detached_states_and_attributes_to_purge( - dt_util.utc_to_timestamp(purge_before) + dt_util.utc_to_timestamp(purge_before), max_bind_vars ) ).all() _LOGGER.debug("Selected %s state ids to remove", len(states)) @@ -460,7 +467,7 @@ def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> tuple[set[int], set[int], set[int], set[int]]: """Return a list of event, state, and attribute ids to purge linked by the event_id. @@ -470,7 +477,7 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( """ events = session.execute( find_legacy_event_state_and_attributes_and_data_ids_to_purge( - dt_util.utc_to_timestamp(purge_before) + dt_util.utc_to_timestamp(purge_before), max_bind_vars ) ).all() _LOGGER.debug("Selected %s event ids to remove", len(events)) @@ -511,8 +518,8 @@ def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) def _purge_batch_attributes_ids( instance: Recorder, session: Session, attributes_ids: set[int] ) -> None: - """Delete old attributes ids in batches of SQLITE_MAX_BIND_VARS.""" - for attributes_ids_chunk in chunked(attributes_ids, SQLITE_MAX_BIND_VARS): + """Delete old attributes ids in batches of max_bind_vars.""" + for attributes_ids_chunk in chunked(attributes_ids, instance.max_bind_vars): deleted_rows = session.execute( delete_states_attributes_rows(attributes_ids_chunk) ) @@ -525,8 +532,8 @@ def _purge_batch_attributes_ids( def _purge_batch_data_ids( instance: Recorder, session: Session, data_ids: set[int] ) -> None: - """Delete old event data ids in batches of SQLITE_MAX_BIND_VARS.""" - for data_ids_chunk in chunked(data_ids, SQLITE_MAX_BIND_VARS): + """Delete old event data ids in batches of max_bind_vars.""" + for data_ids_chunk in chunked(data_ids, instance.max_bind_vars): deleted_rows = session.execute(delete_event_data_rows(data_ids_chunk)) _LOGGER.debug("Deleted %s data events", deleted_rows) @@ -671,7 +678,7 @@ def _purge_filtered_states( session.query(States.state_id, States.attributes_id, States.event_id) .filter(States.metadata_id.in_(metadata_ids_to_purge)) .filter(States.last_updated_ts < purge_before_timestamp) - .limit(SQLITE_MAX_BIND_VARS) + .limit(instance.max_bind_vars) .all() ) if not to_purge: @@ -709,7 +716,7 @@ def _purge_filtered_events( session.query(Events.event_id, Events.data_id) .filter(Events.event_type_id.in_(excluded_event_type_ids)) .filter(Events.time_fired_ts < purge_before_timestamp) - .limit(SQLITE_MAX_BIND_VARS) + .limit(instance.max_bind_vars) .all() ) if not to_purge: @@ -760,7 +767,7 @@ def purge_entity_data( if not selected_metadata_ids: return True - # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states + # Purge a max of max_bind_vars, based on the oldest states # or events record. if not _purge_filtered_states( instance, diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 71a996f0381..c03057b31b2 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -8,7 +8,6 @@ from sqlalchemy import delete, distinct, func, lambda_stmt, select, union_all, u from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select -from .const import SQLITE_MAX_BIND_VARS from .db_schema import ( EventData, Events, @@ -17,6 +16,7 @@ from .db_schema import ( StateAttributes, States, StatesMeta, + Statistics, StatisticsRuns, StatisticsShortTerm, ) @@ -612,44 +612,48 @@ def delete_recorder_runs_rows( ) -def find_events_to_purge(purge_before: float) -> StatementLambdaElement: +def find_events_to_purge( + purge_before: float, max_bind_vars: int +) -> StatementLambdaElement: """Find events to purge.""" return lambda_stmt( lambda: select(Events.event_id, Events.data_id) .filter(Events.time_fired_ts < purge_before) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) -def find_states_to_purge(purge_before: float) -> StatementLambdaElement: +def find_states_to_purge( + purge_before: float, max_bind_vars: int +) -> StatementLambdaElement: """Find states to purge.""" return lambda_stmt( lambda: select(States.state_id, States.attributes_id) .filter(States.last_updated_ts < purge_before) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) def find_short_term_statistics_to_purge( - purge_before: datetime, + purge_before: datetime, max_bind_vars: int ) -> StatementLambdaElement: """Find short term statistics to purge.""" purge_before_ts = purge_before.timestamp() return lambda_stmt( lambda: select(StatisticsShortTerm.id) .filter(StatisticsShortTerm.start_ts < purge_before_ts) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) def find_statistics_runs_to_purge( - purge_before: datetime, + purge_before: datetime, max_bind_vars: int ) -> StatementLambdaElement: """Find statistics_runs to purge.""" return lambda_stmt( lambda: select(StatisticsRuns.run_id) .filter(StatisticsRuns.start < purge_before) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) @@ -659,7 +663,7 @@ def find_latest_statistics_runs_run_id() -> StatementLambdaElement: def find_legacy_event_state_and_attributes_and_data_ids_to_purge( - purge_before: float, + purge_before: float, max_bind_vars: int ) -> StatementLambdaElement: """Find the latest row in the legacy format to purge.""" return lambda_stmt( @@ -668,12 +672,12 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge( ) .outerjoin(States, Events.event_id == States.event_id) .filter(Events.time_fired_ts < purge_before) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) def find_legacy_detached_states_and_attributes_to_purge( - purge_before: float, + purge_before: float, max_bind_vars: int ) -> StatementLambdaElement: """Find states rows with event_id set but not linked event_id in Events.""" return lambda_stmt( @@ -684,7 +688,7 @@ def find_legacy_detached_states_and_attributes_to_purge( (States.last_updated_ts < purge_before) | States.last_updated_ts.is_(None) ) .filter(Events.event_id.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) @@ -693,7 +697,7 @@ def find_legacy_row() -> StatementLambdaElement: return lambda_stmt(lambda: select(func.max(States.event_id))) -def find_events_context_ids_to_migrate() -> StatementLambdaElement: +def find_events_context_ids_to_migrate(max_bind_vars: int) -> StatementLambdaElement: """Find events context_ids to migrate.""" return lambda_stmt( lambda: select( @@ -704,11 +708,11 @@ def find_events_context_ids_to_migrate() -> StatementLambdaElement: Events.context_parent_id, ) .filter(Events.context_id_bin.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) -def find_event_type_to_migrate() -> StatementLambdaElement: +def find_event_type_to_migrate(max_bind_vars: int) -> StatementLambdaElement: """Find events event_type to migrate.""" return lambda_stmt( lambda: select( @@ -716,11 +720,11 @@ def find_event_type_to_migrate() -> StatementLambdaElement: Events.event_type, ) .filter(Events.event_type_id.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) -def find_entity_ids_to_migrate() -> StatementLambdaElement: +def find_entity_ids_to_migrate(max_bind_vars: int) -> StatementLambdaElement: """Find entity_id to migrate.""" return lambda_stmt( lambda: select( @@ -728,7 +732,7 @@ def find_entity_ids_to_migrate() -> StatementLambdaElement: States.entity_id, ) .filter(States.metadata_id.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) @@ -792,7 +796,7 @@ def has_entity_ids_to_migrate() -> StatementLambdaElement: ) -def find_states_context_ids_to_migrate() -> StatementLambdaElement: +def find_states_context_ids_to_migrate(max_bind_vars: int) -> StatementLambdaElement: """Find events context_ids to migrate.""" return lambda_stmt( lambda: select( @@ -803,7 +807,7 @@ def find_states_context_ids_to_migrate() -> StatementLambdaElement: States.context_parent_id, ) .filter(States.context_id_bin.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) @@ -857,3 +861,96 @@ def delete_states_meta_rows(metadata_ids: Iterable[int]) -> StatementLambdaEleme .where(StatesMeta.metadata_id.in_(metadata_ids)) .execution_options(synchronize_session=False) ) + + +def find_unmigrated_short_term_statistics_rows( + max_bind_vars: int, +) -> StatementLambdaElement: + """Find unmigrated short term statistics rows.""" + return lambda_stmt( + lambda: select( + StatisticsShortTerm.id, + StatisticsShortTerm.start, + StatisticsShortTerm.created, + StatisticsShortTerm.last_reset, + ) + .filter(StatisticsShortTerm.start_ts.is_(None)) + .filter(StatisticsShortTerm.start.isnot(None)) + .limit(max_bind_vars) + ) + + +def find_unmigrated_statistics_rows(max_bind_vars: int) -> StatementLambdaElement: + """Find unmigrated statistics rows.""" + return lambda_stmt( + lambda: select( + Statistics.id, Statistics.start, Statistics.created, Statistics.last_reset + ) + .filter(Statistics.start_ts.is_(None)) + .filter(Statistics.start.isnot(None)) + .limit(max_bind_vars) + ) + + +def migrate_single_short_term_statistics_row_to_timestamp( + statistic_id: int, + start_ts: float | None, + created_ts: float | None, + last_reset_ts: float | None, +) -> StatementLambdaElement: + """Migrate a single short term statistics row to timestamp.""" + return lambda_stmt( + lambda: update(StatisticsShortTerm) + .where(StatisticsShortTerm.id == statistic_id) + .values( + start_ts=start_ts, + start=None, + created_ts=created_ts, + created=None, + last_reset_ts=last_reset_ts, + last_reset=None, + ) + .execution_options(synchronize_session=False) + ) + + +def migrate_single_statistics_row_to_timestamp( + statistic_id: int, + start_ts: float | None, + created_ts: float | None, + last_reset_ts: float | None, +) -> StatementLambdaElement: + """Migrate a single statistics row to timestamp.""" + return lambda_stmt( + lambda: update(Statistics) + .where(Statistics.id == statistic_id) + .values( + start_ts=start_ts, + start=None, + created_ts=created_ts, + created=None, + last_reset_ts=last_reset_ts, + last_reset=None, + ) + .execution_options(synchronize_session=False) + ) + + +def delete_duplicate_short_term_statistics_row( + statistic_id: int, +) -> StatementLambdaElement: + """Delete a single duplicate short term statistics row.""" + return lambda_stmt( + lambda: delete(StatisticsShortTerm) + .where(StatisticsShortTerm.id == statistic_id) + .execution_options(synchronize_session=False) + ) + + +def delete_duplicate_statistics_row(statistic_id: int) -> StatementLambdaElement: + """Delete a single duplicate statistics row.""" + return lambda_stmt( + lambda: delete(Statistics) + .where(Statistics.id == statistic_id) + .execution_options(synchronize_session=False) + ) diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index 85266a37939..4c46b1b9faf 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -10,7 +10,6 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventData from ..queries import get_shared_event_datas from ..util import chunked, execute_stmt_lambda_element @@ -95,7 +94,7 @@ class EventDataManager(BaseLRUTableManager[EventData]): """ results: dict[str, int | None] = {} with session.no_autoflush: - for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for hashs_chunk in chunked(hashes, self.recorder.max_bind_vars): for data_id, shared_data in execute_stmt_lambda_element( session, get_shared_event_datas(hashs_chunk), orm_rows=False ): diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index fd03bdd14d2..45b3b96353c 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -9,7 +9,6 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event -from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids from ..tasks import RefreshEventTypesTask @@ -78,7 +77,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): return results with session.no_autoflush: - for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): + for missing_chunk in chunked(missing, self.recorder.max_bind_vars): for event_type_id, event_type in execute_stmt_lambda_element( session, find_event_type_ids(missing_chunk), orm_rows=False ): diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 653ef1689bd..725bacae71c 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -11,7 +11,6 @@ from homeassistant.core import Event from homeassistant.helpers.entity import entity_sources from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StateAttributes from ..queries import get_shared_attributes from ..util import chunked, execute_stmt_lambda_element @@ -108,7 +107,7 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): """ results: dict[str, int | None] = {} with session.no_autoflush: - for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for hashs_chunk in chunked(hashes, self.recorder.max_bind_vars): for attributes_id, shared_attrs in execute_stmt_lambda_element( session, get_shared_attributes(hashs_chunk), orm_rows=False ): diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index b8f6204d318..9b7aa1f7f96 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -8,7 +8,6 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event -from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids from ..util import chunked, execute_stmt_lambda_element @@ -104,7 +103,7 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): update_cache = from_recorder or not self._did_first_load with session.no_autoflush: - for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): + for missing_chunk in chunked(missing, self.recorder.max_bind_vars): for metadata_id, entity_id in execute_stmt_lambda_element( session, find_states_metadata_ids(missing_chunk) ): diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index d438cbede9f..f94601bb2cb 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -31,7 +31,15 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, issue_registry as ir import homeassistant.util.dt as dt_util -from .const import DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect +from .const import ( + DATA_INSTANCE, + DEFAULT_MAX_BIND_VARS, + DOMAIN, + SQLITE_MAX_BIND_VARS, + SQLITE_MODERN_MAX_BIND_VARS, + SQLITE_URL_PREFIX, + SupportedDialect, +) from .db_schema import ( TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, @@ -87,6 +95,7 @@ MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4") MIN_VERSION_MYSQL = _simple_version("8.0.0") MIN_VERSION_PGSQL = _simple_version("12.0") MIN_VERSION_SQLITE = _simple_version("3.31.0") +MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.32.0") # This is the maximum time after the recorder ends the session @@ -471,6 +480,7 @@ def setup_connection_for_dialect( version: AwesomeVersion | None = None slow_range_in_select = False if dialect_name == SupportedDialect.SQLITE: + max_bind_vars = SQLITE_MAX_BIND_VARS if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] dbapi_connection.isolation_level = None # type: ignore[attr-defined] @@ -488,6 +498,9 @@ def setup_connection_for_dialect( version or version_string, "SQLite", MIN_VERSION_SQLITE ) + if version and version > MIN_VERSION_SQLITE_MODERN_BIND_VARS: + max_bind_vars = SQLITE_MODERN_MAX_BIND_VARS + # The upper bound on the cache size is approximately 16MiB of memory execute_on_connection(dbapi_connection, "PRAGMA cache_size = -16384") @@ -506,6 +519,7 @@ def setup_connection_for_dialect( execute_on_connection(dbapi_connection, "PRAGMA foreign_keys=ON") elif dialect_name == SupportedDialect.MYSQL: + max_bind_vars = DEFAULT_MAX_BIND_VARS execute_on_connection(dbapi_connection, "SET session wait_timeout=28800") if first_connection: result = query_on_connection(dbapi_connection, "SELECT VERSION()") @@ -546,6 +560,7 @@ def setup_connection_for_dialect( # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") elif dialect_name == SupportedDialect.POSTGRESQL: + max_bind_vars = DEFAULT_MAX_BIND_VARS if first_connection: # server_version_num was added in 2006 result = query_on_connection(dbapi_connection, "SHOW server_version") @@ -566,6 +581,7 @@ def setup_connection_for_dialect( dialect=SupportedDialect(dialect_name), version=version, optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), + max_bind_vars=max_bind_vars, ) diff --git a/homeassistant/components/safe_mode/__init__.py b/homeassistant/components/recovery_mode/__init__.py similarity index 70% rename from homeassistant/components/safe_mode/__init__.py rename to homeassistant/components/recovery_mode/__init__.py index 3ed2d4476af..46a8d320663 100644 --- a/homeassistant/components/safe_mode/__init__.py +++ b/homeassistant/components/recovery_mode/__init__.py @@ -1,22 +1,22 @@ -"""The Safe Mode integration.""" +"""The Recovery Mode integration.""" from homeassistant.components import persistent_notification from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -DOMAIN = "safe_mode" +DOMAIN = "recovery_mode" CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Safe Mode component.""" + """Set up the Recovery Mode component.""" persistent_notification.async_create( hass, ( - "Home Assistant is running in safe mode. Check [the error" + "Home Assistant is running in recovery mode. Check [the error" " log](/config/logs) to see what went wrong." ), - "Safe Mode", + "Recovery Mode", ) return True diff --git a/homeassistant/components/safe_mode/manifest.json b/homeassistant/components/recovery_mode/manifest.json similarity index 59% rename from homeassistant/components/safe_mode/manifest.json rename to homeassistant/components/recovery_mode/manifest.json index 344b530db2e..1e46a4acde6 100644 --- a/homeassistant/components/safe_mode/manifest.json +++ b/homeassistant/components/recovery_mode/manifest.json @@ -1,10 +1,10 @@ { - "domain": "safe_mode", - "name": "Safe Mode", + "domain": "recovery_mode", + "name": "Recovery Mode", "codeowners": ["@home-assistant/core"], "config_flow": false, "dependencies": ["frontend", "persistent_notification", "cloud"], - "documentation": "https://www.home-assistant.io/integrations/safe_mode", + "documentation": "https://www.home-assistant.io/integrations/recovery_mode", "integration_type": "system", "quality_scale": "internal" } diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index b729e2969d6..004be661f02 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -51,16 +51,6 @@ from .const import DOMAIN from .coordinator import RensonCoordinator from .entity import RensonEntity -OPTIONS_MAPPING = { - "Off": "off", - "Level1": "level1", - "Level2": "level2", - "Level3": "level3", - "Level4": "level4", - "Breeze": "breeze", - "Holiday": "holiday", -} - @dataclass class RensonSensorEntityDescriptionMixin: @@ -294,9 +284,9 @@ class RensonSensor(RensonEntity, SensorEntity): if self.raw_format: self._attr_native_value = value elif self.entity_description.device_class == SensorDeviceClass.ENUM: - self._attr_native_value = OPTIONS_MAPPING.get( - self.api.parse_value(value, self.data_type), None - ) + self._attr_native_value = self.api.parse_value( + value, self.data_type + ).lower() else: self._attr_native_value = self.api.parse_value(value, self.data_type) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 9d9d8d59e88..1c1d8dd96b1 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.7.11"] + "requirements": ["reolink-aio==0.7.12"] } diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 84d39b3d8e2..fd42e69268d 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -78,6 +78,7 @@ SELECT_ENTITIES = ( key="auto_quick_reply_message", translation_key="auto_quick_reply_message", icon="mdi:message-reply-text-outline", + entity_category=EntityCategory.CONFIG, get_options=lambda api, ch: list(api.quick_reply_dict(ch).values()), supported=lambda api, ch: api.supported(ch, "quick_reply"), value=lambda api, ch: api.quick_reply_dict(ch)[api.quick_reply_file(ch)], diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 4a5b415a144..4bc817f9c52 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -129,6 +129,7 @@ SWITCH_ENTITIES = ( key="record", translation_key="record", icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "recording") and api.is_nvr, value=lambda api, ch: api.recording_enabled(ch), method=lambda api, ch, value: api.set_recording(ch, value), @@ -185,6 +186,7 @@ NVR_SWITCH_ENTITIES = ( key="record", translation_key="record", icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "recording"), value=lambda api: api.recording_enabled(), method=lambda api, value: api.set_recording(None, value), diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 1007ee1d2de..dcf790748ec 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -16,10 +16,12 @@ from homeassistant.const import ( CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, + SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType DOMAIN = "rest_command" @@ -58,6 +60,23 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the REST command component.""" + async def reload_service_handler(service: ServiceCall) -> None: + """Remove all rest_commands and load new ones from config.""" + conf = await async_integration_yaml_config(hass, DOMAIN) + + # conf will be None if the configuration can't be parsed + if conf is None: + return + + existing = hass.services.async_services().get(DOMAIN, {}) + for existing_service in existing: + if existing_service == SERVICE_RELOAD: + continue + hass.services.async_remove(DOMAIN, existing_service) + + for name, command_config in conf[DOMAIN].items(): + async_register_rest_command(name, command_config) + @callback def async_register_rest_command(name, command_config): """Create service for rest command.""" @@ -156,4 +175,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for name, command_config in config[DOMAIN].items(): async_register_rest_command(name, command_config) + hass.services.async_register( + DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) + ) + return True diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 320b0fc7c6d..a8f6a6fbb4f 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -69,6 +69,14 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_shortage_status, ), + RoborockBinarySensorDescription( + key="in_cleaning", + translation_key="in_cleaning", + icon="mdi:vacuum", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.in_cleaning, + ), ] diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 6882754f49a..5be48c1f4bf 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.34.6"] + "requirements": ["python-roborock==0.35.0"] } diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 53c536494f9..06cffcc2291 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -28,6 +28,9 @@ }, "entity": { "binary_sensor": { + "in_cleaning": { + "name": "Cleaning" + }, "mop_attached": { "name": "Mop attached" }, @@ -64,7 +67,8 @@ "water_empty": "Water empty", "waste_water_tank_full": "Waste water tank full", "dirty_tank_latch_open": "Dirty tank latch open", - "no_dustbin": "No dustbin" + "no_dustbin": "No dustbin", + "cleaning_tank_full_or_blocked": "Cleaning tank full or blocked" } }, "main_brush_time_left": { diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 85dbbe14cdc..586e2a5f062 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -1,9 +1,11 @@ """The roomba component.""" import asyncio +import contextlib from functools import partial import logging +from typing import Any -from roombapy import RoombaConnectionError, RoombaFactory +from roombapy import Roomba, RoombaConnectionError, RoombaFactory from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry @@ -16,15 +18,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import ( - BLID, - CANCEL_STOP, - CONF_BLID, - CONF_CONTINUOUS, - DOMAIN, - PLATFORMS, - ROOMBA_SESSION, -) +from .const import CONF_BLID, CONF_CONTINUOUS, DOMAIN, PLATFORMS, ROOMBA_SESSION +from .models import RoombaData _LOGGER = logging.getLogger(__name__) @@ -62,16 +57,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def _async_disconnect_roomba(event): await async_disconnect_or_timeout(hass, roomba) - cancel_stop = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, _async_disconnect_roomba + config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect_roomba) ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = { - ROOMBA_SESSION: roomba, - BLID: config_entry.data[CONF_BLID], - CANCEL_STOP: cancel_stop, - } + domain_data = RoombaData(roomba, config_entry.data[CONF_BLID]) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = domain_data await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -81,7 +72,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_connect_or_timeout(hass, roomba): +async def async_connect_or_timeout( + hass: HomeAssistant, roomba: Roomba +) -> dict[str, Any]: """Connect to vacuum.""" try: name = None @@ -106,12 +99,12 @@ async def async_connect_or_timeout(hass, roomba): return {ROOMBA_SESSION: roomba, CONF_NAME: name} -async def async_disconnect_or_timeout(hass, roomba): +async def async_disconnect_or_timeout(hass: HomeAssistant, roomba: Roomba) -> None: """Disconnect to vacuum.""" _LOGGER.debug("Disconnect vacuum") - async with asyncio.timeout(3): - await hass.async_add_executor_job(roomba.disconnect) - return True + with contextlib.suppress(asyncio.TimeoutError): + async with asyncio.timeout(3): + await hass.async_add_executor_job(roomba.disconnect) async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: @@ -125,15 +118,14 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, PLATFORMS ) if unload_ok: - domain_data = hass.data[DOMAIN][config_entry.entry_id] - domain_data[CANCEL_STOP]() - await async_disconnect_or_timeout(hass, roomba=domain_data[ROOMBA_SESSION]) + domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + await async_disconnect_or_timeout(hass, roomba=domain_data.roomba) hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok -def roomba_reported_state(roomba): +def roomba_reported_state(roomba: Roomba) -> dict[str, Any]: """Roomba report.""" return roomba.master_state.get("state", {}).get("reported", {}) diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index cd37e089c9f..007d803fbf4 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -5,8 +5,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import roomba_reported_state -from .const import BLID, DOMAIN, ROOMBA_SESSION +from .const import DOMAIN from .irobot_base import IRobotEntity +from .models import RoombaData async def async_setup_entry( @@ -15,13 +16,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data = hass.data[DOMAIN][config_entry.entry_id] - roomba = domain_data[ROOMBA_SESSION] - blid = domain_data[BLID] + domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + roomba = domain_data.roomba + blid = domain_data.blid status = roomba_reported_state(roomba).get("bin", {}) if "full" in status: roomba_vac = RoombaBinStatus(roomba, blid) - async_add_entities([roomba_vac], True) + async_add_entities([roomba_vac]) class RoombaBinStatus(IRobotEntity, BinarySensorEntity): diff --git a/homeassistant/components/roomba/const.py b/homeassistant/components/roomba/const.py index ae872e0540c..151d3bfb68e 100644 --- a/homeassistant/components/roomba/const.py +++ b/homeassistant/components/roomba/const.py @@ -10,5 +10,3 @@ DEFAULT_CERT = "/etc/ssl/certs/ca-certificates.crt" DEFAULT_CONTINUOUS = True DEFAULT_DELAY = 1 ROOMBA_SESSION = "roomba_session" -BLID = "blid_key" -CANCEL_STOP = "cancel_stop" diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index a48b3638608..ffa4e2d8292 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -94,6 +94,7 @@ class IRobotEntity(Entity): return DeviceInfo( connections=connections, identifiers={(DOMAIN, self.robot_unique_id)}, + serial_number=self.vacuum_state.get("hwPartsRev", {}).get("navSerialNo"), manufacturer="iRobot", model=self._sku, name=str(self._name), @@ -101,10 +102,25 @@ class IRobotEntity(Entity): ) @property - def _battery_level(self): + def battery_level(self): """Return the battery level of the vacuum cleaner.""" return self.vacuum_state.get("batPct") + @property + def run_stats(self): + """Return the run stats.""" + return self.vacuum_state.get("bbrun") + + @property + def mission_stats(self): + """Return the mission stats.""" + return self.vacuum_state.get("bbmssn") + + @property + def battery_stats(self): + """Return the battery stats.""" + return self.vacuum_state.get("bbchg3") + @property def _robot_state(self): """Return the state of the vacuum cleaner.""" @@ -146,11 +162,6 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): super().__init__(roomba, blid) self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 - @property - def battery_level(self): - """Return the battery level of the vacuum cleaner.""" - return self._battery_level - @property def state(self): """Return the state of the vacuum cleaner.""" diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 9e18465922a..8e6b92732eb 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -1,7 +1,7 @@ { "domain": "roomba", "name": "iRobot Roomba and Braava", - "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"], + "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn", "@Xitee1"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/roomba/models.py b/homeassistant/components/roomba/models.py new file mode 100644 index 00000000000..87610bed1ae --- /dev/null +++ b/homeassistant/components/roomba/models.py @@ -0,0 +1,14 @@ +"""The roomba integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from roombapy import Roomba + + +@dataclass +class RoombaData: + """Data for the roomba integration.""" + + roomba: Roomba + blid: str diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index dd74a023ff1..3b2b34af67b 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -1,12 +1,120 @@ -"""Sensor for checking the battery level of Roomba.""" -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +"""Sensor platform for Roomba.""" +from collections.abc import Callable +from dataclasses import dataclass + +from roombapy import Roomba + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import BLID, DOMAIN, ROOMBA_SESSION +from .const import DOMAIN from .irobot_base import IRobotEntity +from .models import RoombaData + + +@dataclass +class RoombaSensorEntityDescriptionMixin: + """Mixin for describing Roomba data.""" + + value_fn: Callable[[IRobotEntity], StateType] + + +@dataclass +class RoombaSensorEntityDescription( + SensorEntityDescription, RoombaSensorEntityDescriptionMixin +): + """Immutable class for describing Roomba data.""" + + +SENSORS: list[RoombaSensorEntityDescription] = [ + RoombaSensorEntityDescription( + key="battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.battery_level, + ), + RoombaSensorEntityDescription( + key="battery_cycles", + translation_key="battery_cycles", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:counter", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.battery_stats.get("nLithChrg") + or self.battery_stats.get("nNimhChrg"), + ), + RoombaSensorEntityDescription( + key="total_cleaning_time", + translation_key="total_cleaning_time", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.run_stats.get("hr"), + ), + RoombaSensorEntityDescription( + key="average_mission_time", + translation_key="average_mission_time", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("aMssnM"), + ), + RoombaSensorEntityDescription( + key="total_missions", + translation_key="total_missions", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Missions", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("nMssn"), + ), + RoombaSensorEntityDescription( + key="successful_missions", + translation_key="successful_missions", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Missions", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("nMssnOk"), + ), + RoombaSensorEntityDescription( + key="canceled_missions", + translation_key="canceled_missions", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Missions", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("nMssnC"), + ), + RoombaSensorEntityDescription( + key="failed_missions", + translation_key="failed_missions", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Missions", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("nMssnF"), + ), + RoombaSensorEntityDescription( + key="scrubs_count", + translation_key="scrubs", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Scrubs", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.run_stats.get("nScrubs"), + entity_registry_enabled_default=False, + ), +] async def async_setup_entry( @@ -15,26 +123,36 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data = hass.data[DOMAIN][config_entry.entry_id] - roomba = domain_data[ROOMBA_SESSION] - blid = domain_data[BLID] - roomba_vac = RoombaBattery(roomba, blid) - async_add_entities([roomba_vac], True) + domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + roomba = domain_data.roomba + blid = domain_data.blid + + async_add_entities( + RoombaSensor(roomba, blid, entity_description) for entity_description in SENSORS + ) -class RoombaBattery(IRobotEntity, SensorEntity): - """Class to hold Roomba Sensor basic info.""" +class RoombaSensor(IRobotEntity, SensorEntity): + """Roomba sensor.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_device_class = SensorDeviceClass.BATTERY - _attr_native_unit_of_measurement = PERCENTAGE + entity_description: RoombaSensorEntityDescription + + def __init__( + self, + roomba: Roomba, + blid: str, + entity_description: RoombaSensorEntityDescription, + ) -> None: + """Initialize Roomba sensor.""" + super().__init__(roomba, blid) + self.entity_description = entity_description @property - def unique_id(self): - """Return the ID of this sensor.""" - return f"battery_{self._blid}" + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self.entity_description.key}_{self._blid}" @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._battery_level + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index 206e8c5bae0..f1816d58613 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -53,6 +53,32 @@ "bin_full": { "name": "Bin full" } + }, + "sensor": { + "battery_cycles": { + "name": "Battery cycles" + }, + "total_cleaning_time": { + "name": "Total cleaning time" + }, + "average_mission_time": { + "name": "Average mission time" + }, + "total_missions": { + "name": "Total missions" + }, + "successful_missions": { + "name": "Successful missions" + }, + "canceled_missions": { + "name": "Canceled missions" + }, + "failed_missions": { + "name": "Failed missions" + }, + "scrubs_count": { + "name": "Scrubs" + } } } } diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 4333f926ba5..b6c0e893b1c 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -7,8 +7,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import roomba_reported_state from .braava import BraavaJet -from .const import BLID, DOMAIN, ROOMBA_SESSION +from .const import DOMAIN from .irobot_base import IRobotVacuum +from .models import RoombaData from .roomba import RoombaVacuum, RoombaVacuumCarpetBoost @@ -18,9 +19,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data = hass.data[DOMAIN][config_entry.entry_id] - roomba = domain_data[ROOMBA_SESSION] - blid = domain_data[BLID] + domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + roomba = domain_data.roomba + blid = domain_data.blid # Get the capabilities of our unit state = roomba_reported_state(roomba) @@ -36,4 +37,4 @@ async def async_setup_entry( constructor = RoombaVacuum roomba_vac = constructor(roomba, blid) - async_add_entities([roomba_vac], True) + async_add_entities([roomba_vac]) diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 2b6373efc24..dbfd7c44730 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -1,8 +1,6 @@ """Base SamsungTV Entity.""" from __future__ import annotations -from typing import cast - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_NAME from homeassistant.helpers import device_registry as dr @@ -16,17 +14,16 @@ from .const import CONF_MANUFACTURER, DOMAIN class SamsungTVEntity(Entity): """Defines a base SamsungTV entity.""" + _attr_has_entity_name = True + def __init__(self, *, bridge: SamsungTVBridge, config_entry: ConfigEntry) -> None: """Initialize the SamsungTV entity.""" self._bridge = bridge self._mac = config_entry.data.get(CONF_MAC) - self._attr_name = config_entry.data.get(CONF_NAME) # Fallback for legacy models that doesn't have a API to retrieve MAC or SerialNumber self._attr_unique_id = config_entry.unique_id or config_entry.entry_id self._attr_device_info = DeviceInfo( - # Instead of setting the device name to the entity name, samsungtv - # should be updated to set has_entity_name = True - name=cast(str | None, self.name), + name=config_entry.data.get(CONF_NAME), manufacturer=config_entry.data.get(CONF_MANUFACTURER), model=config_entry.data.get(CONF_MODEL), ) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 87174b13dd6..14589274da6 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -72,6 +72,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Representation of a Samsung TV.""" _attr_source_list: list[str] + _attr_name = None _attr_device_class = MediaPlayerDeviceClass.TV def __init__( diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index 22857d96659..bbe65d2ac82 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -24,6 +24,7 @@ async def async_setup_entry( class SamsungTVRemote(SamsungTVEntity, RemoteEntity): """Device that sends commands to a SamsungTV.""" + _attr_name = None _attr_should_poll = False async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index acd98b10255..543cefd5b9a 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -39,6 +39,7 @@ create: selector: text: entities: + advanced: true example: | light.tv_back_light: "on" light.ceiling: @@ -51,4 +52,5 @@ create: - light.ceiling - light.kitchen selector: - object: + entity: + multiple: true diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index ff9c60c0b55..0b5e35492de 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -48,11 +48,7 @@ class SchlageLockEntity(SchlageEntity, LockEntity): """Update our internal state attributes.""" self._attr_is_locked = self._lock.is_locked self._attr_is_jammed = self._lock.is_jammed - # Only update changed_by if we get a valid value. This way a previous - # value will stay intact if the latest log message isn't related to a - # lock state change. - if changed_by := self._lock.last_changed_by(self._lock_data.logs): - self._attr_changed_by = changed_by + self._attr_changed_by = self._lock.last_changed_by() async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 3568692c6ca..f474f739904 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.9.1"] + "requirements": ["pyschlage==2023.10.0"] } diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index bdfa3fd9c5a..e96260139da 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -18,8 +18,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.helpers import discovery, entity_registry as er import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_SENSOR_BASE_SCHEMA, @@ -120,3 +121,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> bool: + """Remove Scrape config entry from a device.""" + entity_registry = er.async_get(hass) + for identifier in device.identifiers: + if identifier[0] == DOMAIN and entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, identifier[1] + ): + return False + + return True diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index dc0254cc642..b4305b3948e 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -97,8 +97,6 @@ RESOURCE_SETUP = { vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } -NONE_SENTINEL = "none" - SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -106,45 +104,36 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Required(CONF_DEVICE_CLASS, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[NONE_SENTINEL] - + sorted( - [ - cls.value - for cls in SensorDeviceClass - if cls != SensorDeviceClass.ENUM - ] - ), + options=[ + cls.value for cls in SensorDeviceClass if cls != SensorDeviceClass.ENUM + ], mode=SelectSelectorMode.DROPDOWN, translation_key="device_class", + sort=True, ) ), - vol.Required(CONF_STATE_CLASS, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), + options=[cls.value for cls in SensorStateClass], mode=SelectSelectorMode.DROPDOWN, translation_key="state_class", + sort=True, ) ), - vol.Required(CONF_UNIT_OF_MEASUREMENT, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), + options=[cls.value for cls in UnitOfTemperature], custom_value=True, mode=SelectSelectorMode.DROPDOWN, translation_key="unit_of_measurement", + sort=True, ) ), } -def _strip_sentinel(options: dict[str, Any]) -> None: - """Convert sentinel to None.""" - for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): - if options[key] == NONE_SENTINEL: - options.pop(key) - - async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -171,7 +160,6 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) - _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -203,11 +191,7 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) - for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): - if not suggested_values.get(key): - suggested_values[key] = NONE_SENTINEL - return suggested_values + return dict(handler.options[SENSOR_DOMAIN][idx]) async def validate_sensor_edit( @@ -217,10 +201,14 @@ async def validate_sensor_edit( user_input[CONF_INDEX] = int(user_input[CONF_INDEX]) # Standard behavior is to merge the result with the options. - # In this case, we want to add a sub-item so we update the options directly. + # In this case, we want to add a sub-item so we update the options directly, + # including popping omitted optional schema items. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) - _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) + for key in DATA_SCHEMA_EDIT_SENSOR.schema: + if isinstance(key, vol.Optional) and key not in user_input: + # Key not present, delete keys old value (if present) too + handler.options[SENSOR_DOMAIN][idx].pop(key, None) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index fc2d83dada4..217e69b27df 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -65,7 +65,7 @@ }, "add_sensor": { "data": { - "name": "[%key:component::scrape::config::step::sensor::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]", "index": "[%key:component::scrape::config::step::sensor::data::index%]", "select": "[%key:component::scrape::config::step::sensor::data::select%]", @@ -86,7 +86,7 @@ }, "edit_sensor": { "data": { - "name": "[%key:component::scrape::config::step::sensor::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]", "index": "[%key:component::scrape::config::step::sensor::data::index%]", "select": "[%key:component::scrape::config::step::sensor::data::select%]", @@ -111,10 +111,10 @@ "method": "[%key:component::scrape::config::step::user::data::method%]", "payload": "[%key:component::scrape::config::step::user::data::payload%]", "authentication": "[%key:component::scrape::config::step::user::data::authentication%]", - "username": "[%key:component::scrape::config::step::user::data::username%]", - "password": "[%key:component::scrape::config::step::user::data::password%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", "headers": "[%key:component::scrape::config::step::user::data::headers%]", - "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "timeout": "[%key:component::scrape::config::step::user::data::timeout%]", "encoding": "[%key:component::scrape::config::step::user::data::encoding%]" }, @@ -133,7 +133,6 @@ "selector": { "device_class": { "options": { - "none": "No device class", "date": "[%key:component::sensor::entity_component::date::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", @@ -176,7 +175,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", @@ -187,7 +186,6 @@ }, "state_class": { "options": { - "none": "No state class", "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index e61ca04374f..69bed1af700 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.3"] + "requirements": ["screenlogicpy==0.9.4"] } diff --git a/homeassistant/components/season/manifest.json b/homeassistant/components/season/manifest.json index 3d3338beec7..0e758dc4296 100644 --- a/homeassistant/components/season/manifest.json +++ b/homeassistant/components/season/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["ephem"], "quality_scale": "internal", - "requirements": ["ephem==4.1.2"] + "requirements": ["ephem==4.1.5"] } diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index 29730216899..923bc3eae1f 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -3,9 +3,12 @@ from __future__ import annotations from pysensibo.exceptions import AuthenticationError +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import SensiboDataUpdateCoordinator @@ -53,3 +56,17 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return True + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> bool: + """Remove Sensibo config entry from a device.""" + entity_registry = er.async_get(hass) + for identifier in device.identifiers: + if identifier[0] == DOMAIN and entity_registry.async_get_entity_id( + CLIMATE_DOMAIN, DOMAIN, identifier[1] + ): + return False + + return True diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 9af6139b789..6081c668d89 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -340,22 +340,17 @@ "name": "Pure Boost" } }, - "update": { - "fw_ver_available": { - "name": "Update available" - } - }, "climate": { "climate_device": { "state_attributes": { "fan_mode": { "state": { - "quiet": "Quiet", - "strong": "Strong", + "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]", + "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", - "medium_low": "Medium low", + "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", - "medium_high": "Medium high", + "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" } @@ -363,16 +358,16 @@ "swing_mode": { "state": { "stopped": "[%key:common::state::off%]", - "fixedtop": "Fixed top", - "fixedmiddletop": "Fixed middle top", - "fixedmiddle": "Fixed middle", - "fixedmiddlebottom": "Fixed middle bottom", - "fixedbottom": "Fixed bottom", - "rangetop": "Range top", - "rangemiddle": "Range middle", - "rangebottom": "Range bottom", + "fixedtop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedtop%]", + "fixedmiddletop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedmiddletop%]", + "fixedmiddle": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedmiddle%]", + "fixedmiddlebottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedmiddlebottom%]", + "fixedbottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedbottom%]", + "rangetop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangetop%]", + "rangemiddle": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangemiddle%]", + "rangebottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangebottom%]", "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", - "horizontal": "Horizontal", + "horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]", "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]" } } diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 46b9b860ca6..62e8bbff3ae 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -41,7 +41,6 @@ class SensiboDeviceUpdateEntityDescription( DEVICE_SENSOR_TYPES: tuple[SensiboDeviceUpdateEntityDescription, ...] = ( SensiboDeviceUpdateEntityDescription( key="fw_ver_available", - translation_key="fw_ver_available", device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:rocket-launch", diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 2cab631d1f0..0fa270bb03d 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -11,6 +11,8 @@ import logging from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final +from typing_extensions import override + from homeassistant.config_entries import ConfigEntry # pylint: disable-next=hass-deprecated-import @@ -45,9 +47,11 @@ from homeassistant.const import ( # noqa: F401 DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLTAGE, + EntityCategory, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, @@ -146,6 +150,28 @@ class SensorEntityDescription(EntityDescription): unit_of_measurement: None = None # Type override, use native_unit_of_measurement +def _numeric_state_expected( + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | str | None, + native_unit_of_measurement: str | None, + suggested_display_precision: int | None, +) -> bool: + """Return true if the sensor must be numeric.""" + # Note: the order of the checks needs to be kept aligned + # with the checks in `state` property. + if device_class in NON_NUMERIC_DEVICE_CLASSES: + return False + if ( + state_class is not None + or native_unit_of_measurement is not None + or suggested_display_precision is not None + ): + return True + # Sensors with custom device classes will have the device class + # converted to None and are not considered numeric + return device_class is not None + + class SensorEntity(Entity): """Base class for sensor entities.""" @@ -249,6 +275,11 @@ class SensorEntity(Entity): async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" await super().async_internal_added_to_hass() + if self.entity_category == EntityCategory.CONFIG: + raise HomeAssistantError( + f"Entity {self.entity_id} cannot be added as the entity category is set to config" + ) + if not self.registry_entry: return self._async_read_entity_options() @@ -262,6 +293,7 @@ class SensorEntity(Entity): return self.device_class not in (None, SensorDeviceClass.ENUM) @property + @override def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -274,20 +306,12 @@ class SensorEntity(Entity): @property def _numeric_state_expected(self) -> bool: """Return true if the sensor must be numeric.""" - # Note: the order of the checks needs to be kept aligned - # with the checks in `state` property. - device_class = try_parse_enum(SensorDeviceClass, self.device_class) - if device_class in NON_NUMERIC_DEVICE_CLASSES: - return False - if ( - self.state_class is not None - or self.native_unit_of_measurement is not None - or self.suggested_display_precision is not None - ): - return True - # Sensors with custom device classes will have the device class - # converted to None and are not considered numeric - return device_class is not None + return _numeric_state_expected( + try_parse_enum(SensorDeviceClass, self.device_class), + self.state_class, + self.native_unit_of_measurement, + self.suggested_display_precision, + ) @property def options(self) -> list[str] | None: @@ -317,6 +341,7 @@ class SensorEntity(Entity): return None @property + @override def capability_attributes(self) -> Mapping[str, Any] | None: """Return the capability attributes.""" if state_class := self.state_class: @@ -362,13 +387,12 @@ class SensorEntity(Entity): @final @property + @override def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" if last_reset := self.last_reset: - if ( - self.state_class != SensorStateClass.TOTAL - and not self._last_reset_reported - ): + state_class = self.state_class + if state_class != SensorStateClass.TOTAL and not self._last_reset_reported: self._last_reset_reported = True report_issue = self._suggest_report_issue() # This should raise in Home Assistant Core 2022.5 @@ -381,11 +405,11 @@ class SensorEntity(Entity): ), self.entity_id, type(self), - self.state_class, + state_class, report_issue, ) - if self.state_class == SensorStateClass.TOTAL: + if state_class == SensorStateClass.TOTAL: return {ATTR_LAST_RESET: last_reset.isoformat()} return None @@ -439,6 +463,7 @@ class SensorEntity(Entity): @final @property + @override def unit_of_measurement(self) -> str | None: """Return the unit of measurement of the entity, after unit conversion.""" # Highest priority, for registered entities: unit set by user,with fallback to @@ -457,9 +482,9 @@ class SensorEntity(Entity): native_unit_of_measurement = self.native_unit_of_measurement if ( - self.device_class == SensorDeviceClass.TEMPERATURE - and native_unit_of_measurement + native_unit_of_measurement in {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} + and self.device_class == SensorDeviceClass.TEMPERATURE ): return self.hass.config.units.temperature_unit @@ -468,6 +493,7 @@ class SensorEntity(Entity): @final @property + @override 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 @@ -576,7 +602,9 @@ class SensorEntity(Entity): # If the sensor has neither a device class, a state class, a unit of measurement # nor a precision then there are no further checks or conversions - if not self._numeric_state_expected: + if not _numeric_state_expected( + device_class, state_class, native_unit_of_measurement, suggested_precision + ): return value # From here on a numerical value is expected diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index b13d7cd0d1f..3cf1dc975ec 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -32,6 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources +from homeassistant.loader import async_suggest_report_issue from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -256,20 +257,10 @@ def _normalize_states( def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: """Suggest to report an issue.""" entity_info = entity_sources(hass).get(entity_id) - domain = entity_info["domain"] if entity_info else None - custom_component = entity_info["custom_component"] if entity_info else None - report_issue = "" - if custom_component: - 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" - ) - if domain: - report_issue += f"+label%3A%22integration%3A+{domain}%22" - return report_issue + return async_suggest_report_issue( + hass, integration_domain=entity_info["domain"] if entity_info else None + ) def warn_dip( diff --git a/homeassistant/components/sensor/websocket_api.py b/homeassistant/components/sensor/websocket_api.py index 2457bfcabe3..a98c4b25392 100644 --- a/homeassistant/components/sensor/websocket_api.py +++ b/homeassistant/components/sensor/websocket_api.py @@ -8,13 +8,19 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DEVICE_CLASS_UNITS, UNIT_CONVERTERS +from .const import ( + DEVICE_CLASS_UNITS, + NON_NUMERIC_DEVICE_CLASSES, + UNIT_CONVERTERS, + SensorDeviceClass, +) @callback def async_setup(hass: HomeAssistant) -> None: """Set up the sensor websocket API.""" websocket_api.async_register_command(hass, ws_device_class_units) + websocket_api.async_register_command(hass, ws_numeric_device_classes) @callback @@ -36,3 +42,19 @@ def ws_device_class_units( key=lambda s: str.casefold(str(s)), ) connection.send_result(msg["id"], {"units": convertible_units}) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "sensor/numeric_device_classes", + } +) +def ws_numeric_device_classes( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Return numeric sensor device classes.""" + numeric_device_classes = set(SensorDeviceClass) - NON_NUMERIC_DEVICE_CLASSES + connection.send_result( + msg["id"], {"numeric_device_classes": list(numeric_device_classes)} + ) diff --git a/homeassistant/components/sensorpro/strings.json b/homeassistant/components/sensorpro/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/sensorpro/strings.json +++ b/homeassistant/components/sensorpro/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/sensorpush/strings.json b/homeassistant/components/sensorpush/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/sensorpush/strings.json +++ b/homeassistant/components/sensorpush/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 8815986d368..5e4fb80688d 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, __version__ as current_version, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, get_release_channel from homeassistant.helpers import config_validation as cv, entity_platform, instance_id from homeassistant.helpers.event import async_call_later from homeassistant.helpers.system_info import async_get_system_info @@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Additional/extra data collection - channel = get_channel(current_version) + channel = get_release_channel() huuid = await instance_id.async_get(hass) system_info = await async_get_system_info(hass) custom_components = await async_get_custom_components(hass) @@ -110,17 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def get_channel(version: str) -> str: - """Find channel based on version number.""" - if "dev0" in version: - return "dev" - if "dev" in version: - return "nightly" - if "b" in version: - return "beta" - return "stable" - - def process_before_send( hass: HomeAssistant, options: MappingProxyType[str, Any], diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 2b730648e22..80b428b908e 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==10.0.1"] + "requirements": ["Pillow==10.1.0"] } diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index a9712e62d25..35c18511860 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -148,6 +148,7 @@ class BlockSleepingClimate( self.last_state_attributes: Mapping[str, Any] self._preset_modes: list[str] = [] self._last_target_temp = SHTRV_01_TEMPERATURE_SETTINGS["default"] + self._attr_name = coordinator.name if self.block is not None and self.device_block is not None: self._unique_id = f"{self.coordinator.mac}-{self.block.description}" @@ -160,6 +161,9 @@ class BlockSleepingClimate( ] elif entry is not None: self._unique_id = entry.unique_id + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}, + ) self._channel = cast(int, self._unique_id.split("_")[1]) @@ -173,11 +177,6 @@ class BlockSleepingClimate( """Set unique id of entity.""" return self._unique_id - @property - def name(self) -> str: - """Name of entity.""" - return self.coordinator.name - @property def target_temperature(self) -> float | None: """Set target temperature.""" @@ -256,13 +255,6 @@ class BlockSleepingClimate( """Preset available modes.""" return self._preset_modes - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.coordinator.mac)}, - ) - def _check_is_off(self) -> bool: """Return if valve is off or on.""" return bool( @@ -354,7 +346,7 @@ class BlockSleepingClimate( severity=ir.IssueSeverity.ERROR, translation_key="device_not_calibrated", translation_placeholders={ - "device_name": self.name, + "device_name": self.coordinator.name, "ip_address": self.coordinator.device.ip_address, }, ) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 1a8081b2053..e648a80420a 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -58,7 +58,7 @@ from .const import ( UPDATE_PERIOD_MULTIPLIER, BLEScannerMode, ) -from .utils import device_update_info, get_rpc_device_wakeup_period +from .utils import get_rpc_device_wakeup_period, update_device_fw_info _DeviceT = TypeVar("_DeviceT", bound="BlockDevice|RpcDevice") @@ -295,8 +295,6 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except InvalidAuthError: self.entry.async_start_reauth(self.hass) - else: - device_update_info(self.hass, self.device, self.entry) @callback def _async_handle_update( @@ -376,16 +374,13 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): if self.device.status["uptime"] > 2 * REST_SENSORS_UPDATE_INTERVAL: return - old_firmware = self.device.firmware_version await self.device.update_shelly() - if self.device.firmware_version == old_firmware: - return except DeviceConnectionError as err: raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except InvalidAuthError: self.entry.async_start_reauth(self.hass) else: - device_update_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.entry) class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): @@ -533,7 +528,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): LOGGER.debug("Reconnecting to Shelly RPC Device - %s", self.name) try: await self.device.initialize() - device_update_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.entry) except DeviceConnectionError as err: raise UpdateFailed(f"Device disconnected: {repr(err)}") from err except InvalidAuthError: @@ -619,6 +614,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self.hass.async_create_task(self._async_disconnected()) elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) + if self.sleep_period: + update_device_fw_info(self.hass, self.device, self.entry) elif update_type is RpcUpdateType.EVENT and (event := self.device.event): self._async_device_event_handler(event) diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index f2020597277..95f387f8f97 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -25,7 +25,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up switches for device.""" + """Set up covers for device.""" if get_device_entry_gen(config_entry) == 2: return async_setup_rpc_entry(hass, config_entry, async_add_entities) @@ -70,14 +70,14 @@ class BlockShellyCover(ShellyBlockEntity, CoverEntity): """Entity that controls a cover on block based Shelly devices.""" _attr_device_class = CoverDeviceClass.SHUTTER + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: """Initialize block cover.""" super().__init__(coordinator, block) self.control_result: dict[str, Any] | None = None - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) if self.coordinator.device.settings["rollers"][0]["positioning"]: self._attr_supported_features |= CoverEntityFeature.SET_POSITION @@ -146,14 +146,14 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity): """Entity that controls a cover on RPC based Shelly devices.""" _attr_device_class = CoverDeviceClass.SHUTTER + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize rpc cover.""" super().__init__(coordinator, f"cover:{id_}") self._id = id_ - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) if self.status["pos_control"]: self._attr_supported_features |= CoverEntityFeature.SET_POSITION diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 5afa5f8b727..368a997c62e 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -326,7 +326,6 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): super().__init__(coordinator) self.block = block self._attr_name = get_block_entity_name(coordinator.device, block) - self._attr_should_poll = False self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) @@ -364,7 +363,6 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Initialize Shelly entity.""" super().__init__(coordinator) self.key = key - self._attr_should_poll = False self._attr_device_info = { "connections": {(CONNECTION_NETWORK_MAC, coordinator.mac)} } @@ -571,7 +569,6 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.block: Block | None = block # type: ignore[assignment] self.entity_description = description - self._attr_should_poll = False self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) @@ -621,6 +618,13 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): super()._update_callback() return + async def async_update(self) -> None: + """Update the entity.""" + LOGGER.info( + "Entity %s comes from a sleeping device, update is not possible", + self.entity_id, + ) + class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): """Helper class to represent a sleeping rpc attribute.""" @@ -643,7 +647,6 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.attribute = attribute self.entity_description = description - self._attr_should_poll = False self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) @@ -658,3 +661,10 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): ) elif entry is not None: self._attr_name = cast(str, entry.original_name) + + async def async_update(self) -> None: + """Update the entity.""" + LOGGER.info( + "Entity %s comes from a sleeping device, update is not possible", + self.entity_id, + ) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 1b0fedd5cda..1b5cf911e85 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -122,7 +122,6 @@ async def async_setup_entry( class ShellyBlockEvent(ShellyBlockEntity, EventEntity): """Represent Block event entity.""" - _attr_should_poll = False entity_description: ShellyBlockEventDescription def __init__( @@ -160,7 +159,6 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity): class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): """Represent RPC event entity.""" - _attr_should_poll = False entity_description: ShellyRpcEventDescription def __init__( diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index b64b76534be..4d25812361c 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -375,13 +375,10 @@ def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: @callback -def device_update_info( +def update_device_fw_info( hass: HomeAssistant, shellydevice: BlockDevice | RpcDevice, entry: ConfigEntry ) -> None: - """Update device registry info.""" - - LOGGER.debug("Updating device registry info for %s", entry.title) - + """Update the firmware version information in the device registry.""" assert entry.unique_id dev_reg = dr_async_get(hass) @@ -389,6 +386,11 @@ def device_update_info( identifiers={(DOMAIN, entry.entry_id)}, connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, ): + if device.sw_version == shellydevice.firmware_version: + return + + LOGGER.debug("Updating device registry info for %s", entry.title) + dev_reg.async_update_device(device.id, sw_version=shellydevice.firmware_version) diff --git a/homeassistant/components/shiftr/__init__.py b/homeassistant/components/shiftr/__init__.py deleted file mode 100644 index 6f4282915ac..00000000000 --- a/homeassistant/components/shiftr/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Support for Shiftr.io.""" -import paho.mqtt.client as mqtt -import voluptuous as vol - -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, - EVENT_STATE_CHANGED, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType - -DOMAIN = "shiftr" - -SHIFTR_BROKER = "broker.shiftr.io" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Initialize the Shiftr.io MQTT consumer.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - - client_id = "HomeAssistant" - port = 1883 - keepalive = 600 - - mqttc = mqtt.Client(client_id, protocol=mqtt.MQTTv311) - mqttc.username_pw_set(username, password=password) - mqttc.connect(SHIFTR_BROKER, port=port, keepalive=keepalive) - - def stop_shiftr(event): - """Stop the Shiftr.io MQTT component.""" - mqttc.disconnect() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_shiftr) - - def shiftr_event_listener(event): - """Listen for new messages on the bus and sends them to Shiftr.io.""" - state = event.data.get("new_state") - topic = state.entity_id.replace(".", "/") - - try: - _state = state_helper.state_as_number(state) - except ValueError: - _state = state.state - - try: - mqttc.publish(topic, _state, qos=0, retain=False) - - if state.attributes: - for attribute, data in state.attributes.items(): - mqttc.publish( - f"/{topic}/{attribute}", str(data), qos=0, retain=False - ) - except RuntimeError: - pass - - hass.bus.listen(EVENT_STATE_CHANGED, shiftr_event_listener) - - return True diff --git a/homeassistant/components/shiftr/manifest.json b/homeassistant/components/shiftr/manifest.json deleted file mode 100644 index 6c524912e77..00000000000 --- a/homeassistant/components/shiftr/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "shiftr", - "name": "shiftr.io", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/shiftr", - "iot_class": "cloud_push", - "loggers": ["paho"], - "requirements": ["paho-mqtt==1.6.1"] -} diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 3dc26fe007a..e2f04b5d880 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,21 +1,22 @@ """Support to manage a shopping list.""" +from collections.abc import Callable from http import HTTPStatus import logging -from typing import Any +from typing import Any, cast import uuid import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import frontend, http, websocket_api +from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME +from homeassistant.const import ATTR_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import JsonArrayType, load_json_array +from homeassistant.util.json import JsonValueType, load_json_array from .const import ( ATTR_REVERSE, @@ -32,6 +33,8 @@ from .const import ( SERVICE_SORT, ) +PLATFORMS = [Platform.TODO] + ATTR_COMPLETE = "complete" _LOGGER = logging.getLogger(__name__) @@ -169,10 +172,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.http.register_view(UpdateShoppingListItemView) hass.http.register_view(ClearCompletedItemsView) - frontend.async_register_built_in_panel( - hass, "shopping-list", "shopping_list", "mdi:cart" - ) - websocket_api.async_register_command(hass, websocket_handle_items) websocket_api.async_register_command(hass, websocket_handle_add) websocket_api.async_register_command(hass, websocket_handle_remove) @@ -180,6 +179,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b websocket_api.async_register_command(hass, websocket_handle_clear) websocket_api.async_register_command(hass, websocket_handle_reorder) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True @@ -193,13 +194,15 @@ class ShoppingData: def __init__(self, hass: HomeAssistant) -> None: """Initialize the shopping list.""" self.hass = hass - self.items: JsonArrayType = [] + self.items: list[dict[str, JsonValueType]] = [] + self._listeners: list[Callable[[], None]] = [] - async def async_add(self, name, context=None): + async def async_add(self, name, complete=False, context=None): """Add a shopping list item.""" - item = {"name": name, "id": uuid.uuid4().hex, "complete": False} + item = {"name": name, "id": uuid.uuid4().hex, "complete": complete} self.items.append(item) await self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "add", "item": item}, @@ -207,21 +210,43 @@ class ShoppingData: ) return item - async def async_remove(self, item_id, context=None): + async def async_remove( + self, item_id: str, context=None + ) -> dict[str, JsonValueType] | None: """Remove a shopping list item.""" - item = next((itm for itm in self.items if itm["id"] == item_id), None) - - if item is None: - raise NoMatchingShoppingListItem - - self.items.remove(item) - await self.hass.async_add_executor_job(self.save) - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "remove", "item": item}, - context=context, + removed = await self.async_remove_items( + item_ids=set({item_id}), context=context ) - return item + return next(iter(removed), None) + + async def async_remove_items( + self, item_ids: set[str], context=None + ) -> list[dict[str, JsonValueType]]: + """Remove a shopping list item.""" + items_dict: dict[str, dict[str, JsonValueType]] = {} + for itm in self.items: + item_id = cast(str, itm["id"]) + items_dict[item_id] = itm + removed = [] + for item_id in item_ids: + _LOGGER.debug( + "Removing %s", + ) + if not (item := items_dict.pop(item_id, None)): + raise NoMatchingShoppingListItem( + "Item '{item_id}' not found in shopping list" + ) + removed.append(item) + self.items = list(items_dict.values()) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + for item in removed: + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "remove", "item": item}, + context=context, + ) + return removed async def async_update(self, item_id, info, context=None): """Update a shopping list item.""" @@ -233,6 +258,7 @@ class ShoppingData: info = ITEM_UPDATE_SCHEMA(info) item.update(info) await self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "update", "item": item}, @@ -244,6 +270,7 @@ class ShoppingData: """Clear completed items.""" self.items = [itm for itm in self.items if not itm["complete"]] await self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "clear"}, @@ -255,6 +282,7 @@ class ShoppingData: for item in self.items: item.update(info) await self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "update_list"}, @@ -287,16 +315,42 @@ class ShoppingData: new_items.append(all_items_mapping[key]) self.items = new_items self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "reorder"}, context=context, ) + async def async_move_item(self, uid: str, previous: str | None = None) -> None: + """Re-order a shopping list item.""" + if uid == previous: + return + item_idx = {cast(str, itm["id"]): idx for idx, itm in enumerate(self.items)} + if uid not in item_idx: + raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") + if previous and previous not in item_idx: + raise NoMatchingShoppingListItem( + f"Item '{previous}' not found in shopping list" + ) + dst_idx = item_idx[previous] + 1 if previous else 0 + src_idx = item_idx[uid] + src_item = self.items.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + self.items.insert(dst_idx, src_item) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "reorder"}, + ) + 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._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "sorted"}, @@ -306,9 +360,12 @@ class ShoppingData: async def async_load(self) -> None: """Load items.""" - def load() -> JsonArrayType: + def load() -> list[dict[str, JsonValueType]]: """Load the items synchronously.""" - return load_json_array(self.hass.config.path(PERSISTENCE)) + return cast( + list[dict[str, JsonValueType]], + load_json_array(self.hass.config.path(PERSISTENCE)), + ) self.items = await self.hass.async_add_executor_job(load) @@ -316,6 +373,20 @@ class ShoppingData: """Save the items.""" save_json(self.hass.config.path(PERSISTENCE), self.items) + def async_add_listener(self, cb: Callable[[], None]) -> Callable[[], None]: + """Add a listener to notify when data is updated.""" + + def unsub(): + self._listeners.remove(cb) + + self._listeners.append(cb) + return unsub + + def _async_notify(self) -> None: + """Notify all listeners that data has been updated.""" + for listener in self._listeners: + listener() + class ShoppingListView(http.HomeAssistantView): """View to retrieve shopping list content.""" @@ -397,7 +468,9 @@ async def websocket_handle_add( msg: dict[str, Any], ) -> None: """Handle adding item to shopping_list.""" - item = await hass.data[DOMAIN].async_add(msg["name"], connection.context(msg)) + item = await hass.data[DOMAIN].async_add( + msg["name"], context=connection.context(msg) + ) connection.send_message(websocket_api.result_message(msg["id"], item)) diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py index c519123a414..22553d9c316 100644 --- a/homeassistant/components/shopping_list/const.py +++ b/homeassistant/components/shopping_list/const.py @@ -1,6 +1,9 @@ """All constants related to the shopping list component.""" + +from homeassistant.const import EVENT_SHOPPING_LIST_UPDATED # noqa: F401 + DOMAIN = "shopping_list" -EVENT_SHOPPING_LIST_UPDATED = "shopping_list_updated" + ATTR_REVERSE = "reverse" diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json index ddac4713fac..c184a1d2227 100644 --- a/homeassistant/components/shopping_list/strings.json +++ b/homeassistant/components/shopping_list/strings.json @@ -74,5 +74,12 @@ } } } + }, + "entity": { + "todo": { + "shopping_list": { + "name": "[%key:component::shopping_list::title%]" + } + } } } diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py new file mode 100644 index 00000000000..d89f376d662 --- /dev/null +++ b/homeassistant/components/shopping_list/todo.py @@ -0,0 +1,109 @@ +"""A shopping list todo platform.""" + +from typing import Any, cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import NoMatchingShoppingListItem, ShoppingData +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the shopping_list todo platform.""" + shopping_data = hass.data[DOMAIN] + entity = ShoppingTodoListEntity(shopping_data, unique_id=config_entry.entry_id) + async_add_entities([entity], True) + + +class ShoppingTodoListEntity(TodoListEntity): + """A To-do List representation of the Shopping List.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:cart" + _attr_translation_key = "shopping_list" + _attr_should_poll = False + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + ) + + def __init__(self, data: ShoppingData, unique_id: str) -> None: + """Initialize ShoppingTodoListEntity.""" + self._attr_unique_id = unique_id + self._data = data + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + await self._data.async_add( + item.summary, complete=(item.status == TodoItemStatus.COMPLETED) + ) + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item to the To-do list.""" + data: dict[str, Any] = {} + if item.summary: + data["name"] = item.summary + if item.status: + data["complete"] = item.status == TodoItemStatus.COMPLETED + try: + await self._data.async_update(item.uid, data) + except NoMatchingShoppingListItem as err: + raise HomeAssistantError( + f"Shopping list item '{item.uid}' was not found" + ) from err + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Add an item to the To-do list.""" + await self._data.async_remove_items(set(uids)) + + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Re-order an item to the To-do list.""" + + try: + await self._data.async_move_item(uid, previous_uid) + except NoMatchingShoppingListItem as err: + raise HomeAssistantError( + f"Shopping list item '{uid}' could not be re-ordered" + ) from err + + async def async_added_to_hass(self) -> None: + """Entity has been added to hass.""" + # Shopping list integration doesn't currently support config entry unload + # so this code may not be used in practice, however it is here in case + # this changes in the future. + self.async_on_remove(self._data.async_add_listener(self.async_write_ha_state)) + + @property + def todo_items(self) -> list[TodoItem]: + """Get items in the To-do list.""" + results = [] + for item in self._data.items: + if cast(bool, item["complete"]): + status = TodoItemStatus.COMPLETED + else: + status = TodoItemStatus.NEEDS_ACTION + results.append( + TodoItem( + summary=cast(str, item["name"]), + uid=cast(str, item["id"]), + status=status, + ) + ) + return results diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index d1bc97da7a8..208e2d31de4 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==10.0.1", "simplehound==0.3"] + "requirements": ["Pillow==10.1.0", "simplehound==0.3"] } diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index f54da815b26..a35a92bf257 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -73,7 +73,6 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): self._attr_native_value = self.meter.reading self.async_write_ha_state() - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self): """Subscribe to updates.""" await super().async_added_to_hass() diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py index 896d3f8b5a8..479d1d648b8 100644 --- a/homeassistant/components/snapcast/config_flow.py +++ b/homeassistant/components/snapcast/config_flow.py @@ -51,13 +51,3 @@ class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): 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/media_player.py b/homeassistant/components/snapcast/media_player.py index f0b6eccf8b4..ae2917a106d 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -1,24 +1,19 @@ """Support for interacting with Snapcast clients.""" from __future__ import annotations -import logging - -from snapcast.control.server import CONTROL_PORT, Snapserver +from snapcast.control.server import Snapserver import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +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 ( ATTR_LATENCY, @@ -35,12 +30,6 @@ from .const import ( SERVICE_UNJOIN, ) -_LOGGER = logging.getLogger(__name__) - -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, @@ -93,37 +82,6 @@ async def async_setup_entry( ].hass_async_add_entities = async_add_entities -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, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.11.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Snapcast", - }, - ) - - 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): """Handle the entity service join.""" if not isinstance(entity, SnapcastClientDevice): diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 696b079fd5e..7ca31bae618 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -3,9 +3,8 @@ from __future__ import annotations import binascii import logging +import sys -from pysnmp.entity import config as cfg -from pysnmp.entity.rfc3413.oneliner import cmdgen import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -15,6 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -26,6 +26,11 @@ from .const import ( DEFAULT_COMMUNITY, ) +if sys.version_info < (3, 12): + from pysnmp.entity import config as cfg + from pysnmp.entity.rfc3413.oneliner import cmdgen + + _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( @@ -41,6 +46,10 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "SNMP is not supported on Python 3.12. Please use Python 3.11." + ) scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index a5915183ad0..58cd12d611f 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -3,20 +3,8 @@ from __future__ import annotations from datetime import timedelta import logging +import sys -from pysnmp.error import PySnmpError -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - Udp6TransportTarget, - UdpTransportTarget, - UsmUserData, - getCmd, -) import voluptuous as vol from homeassistant.components.sensor import CONF_STATE_CLASS, PLATFORM_SCHEMA @@ -33,6 +21,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -67,6 +56,21 @@ from .const import ( SNMP_VERSIONS, ) +if sys.version_info < (3, 12): + from pysnmp.error import PySnmpError + import pysnmp.hlapi.asyncio as hlapi + from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + Udp6TransportTarget, + UdpTransportTarget, + UsmUserData, + getCmd, + ) + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) @@ -111,6 +115,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP sensor.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "SNMP is not supported on Python 3.12. Please use Python 3.11." + ) host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index d0fe393d550..e94c6991601 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -2,34 +2,9 @@ from __future__ import annotations import logging +import sys from typing import Any -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - UdpTransportTarget, - UsmUserData, - getCmd, - setCmd, -) -from pysnmp.proto.rfc1902 import ( - Counter32, - Counter64, - Gauge32, - Integer, - Integer32, - IpAddress, - Null, - ObjectIdentifier, - OctetString, - Opaque, - TimeTicks, - Unsigned32, -) import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity @@ -42,6 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -67,6 +43,34 @@ from .const import ( SNMP_VERSIONS, ) +if sys.version_info < (3, 12): + import pysnmp.hlapi.asyncio as hlapi + from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + UdpTransportTarget, + UsmUserData, + getCmd, + setCmd, + ) + from pysnmp.proto.rfc1902 import ( + Counter32, + Counter64, + Gauge32, + Integer, + Integer32, + IpAddress, + Null, + ObjectIdentifier, + OctetString, + Opaque, + TimeTicks, + Unsigned32, + ) + _LOGGER = logging.getLogger(__name__) CONF_COMMAND_OID = "command_oid" @@ -77,21 +81,22 @@ DEFAULT_COMMUNITY = "private" DEFAULT_PAYLOAD_OFF = 0 DEFAULT_PAYLOAD_ON = 1 -MAP_SNMP_VARTYPES = { - "Counter32": Counter32, - "Counter64": Counter64, - "Gauge32": Gauge32, - "Integer32": Integer32, - "Integer": Integer, - "IpAddress": IpAddress, - "Null": Null, - # some work todo to support tuple ObjectIdentifier, this just supports str - "ObjectIdentifier": ObjectIdentifier, - "OctetString": OctetString, - "Opaque": Opaque, - "TimeTicks": TimeTicks, - "Unsigned32": Unsigned32, -} +if sys.version_info < (3, 12): + MAP_SNMP_VARTYPES = { + "Counter32": Counter32, + "Counter64": Counter64, + "Gauge32": Gauge32, + "Integer32": Integer32, + "Integer": Integer, + "IpAddress": IpAddress, + "Null": Null, + # some work todo to support tuple ObjectIdentifier, this just supports str + "ObjectIdentifier": ObjectIdentifier, + "OctetString": OctetString, + "Opaque": Opaque, + "TimeTicks": TimeTicks, + "Unsigned32": Unsigned32, + } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -127,6 +132,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP switch.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "SNMP is not supported on Python 3.12. Please use Python 3.11." + ) name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index bc1e68db02f..b38e105260c 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index f2c073c6918..5e298ae2a6f 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -50,7 +51,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="lifetime_energy", json_key="lifeTimeData", - name="Lifetime energy", + translation_key="lifetime_energy", icon="mdi:solar-power", state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -59,7 +60,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="energy_this_year", json_key="lastYearData", - name="Energy this year", + translation_key="energy_this_year", entity_registry_enabled_default=False, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -68,7 +69,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="energy_this_month", json_key="lastMonthData", - name="Energy this month", + translation_key="energy_this_month", entity_registry_enabled_default=False, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -77,7 +78,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="energy_today", json_key="lastDayData", - name="Energy today", + translation_key="energy_today", entity_registry_enabled_default=False, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -86,7 +87,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="current_power", json_key="currentPower", - name="Current Power", + translation_key="current_power", icon="mdi:solar-power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -95,71 +96,71 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="site_details", json_key="status", - name="Site details", + translation_key="site_details", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="meters", json_key="meters", - name="Meters", + translation_key="meters", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="sensors", json_key="sensors", - name="Sensors", + translation_key="sensors", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="gateways", json_key="gateways", - name="Gateways", + translation_key="gateways", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="batteries", json_key="batteries", - name="Batteries", + translation_key="batteries", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="inverters", json_key="inverters", - name="Inverters", + translation_key="inverters", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="power_consumption", json_key="LOAD", - name="Power Consumption", + translation_key="power_consumption", entity_registry_enabled_default=False, icon="mdi:flash", ), SolarEdgeSensorEntityDescription( key="solar_power", json_key="PV", - name="Solar Power", + translation_key="solar_power", entity_registry_enabled_default=False, icon="mdi:solar-power", ), SolarEdgeSensorEntityDescription( key="grid_power", json_key="GRID", - name="Grid Power", + translation_key="grid_power", entity_registry_enabled_default=False, icon="mdi:power-plug", ), SolarEdgeSensorEntityDescription( key="storage_power", json_key="STORAGE", - name="Storage Power", + translation_key="storage_power", entity_registry_enabled_default=False, icon="mdi:car-battery", ), SolarEdgeSensorEntityDescription( key="purchased_energy", json_key="Purchased", - name="Imported Energy", + translation_key="purchased_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -168,7 +169,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="production_energy", json_key="Production", - name="Production Energy", + translation_key="production_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -177,7 +178,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="consumption_energy", json_key="Consumption", - name="Consumption Energy", + translation_key="consumption_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -186,7 +187,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="selfconsumption_energy", json_key="SelfConsumption", - name="SelfConsumption Energy", + translation_key="selfconsumption_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -195,7 +196,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="feedin_energy", json_key="FeedIn", - name="Exported Energy", + translation_key="feedin_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -204,7 +205,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="storage_level", json_key="STORAGE", - name="Storage Level", + translation_key="storage_level", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -221,9 +222,7 @@ async def async_setup_entry( # Add the needed sensors to hass api: Solaredge = hass.data[DOMAIN][entry.entry_id][DATA_API_CLIENT] - sensor_factory = SolarEdgeSensorFactory( - hass, entry.title, entry.data[CONF_SITE_ID], api - ) + sensor_factory = SolarEdgeSensorFactory(hass, entry.data[CONF_SITE_ID], api) for service in sensor_factory.all_services: service.async_setup() await service.coordinator.async_refresh() @@ -239,11 +238,8 @@ async def async_setup_entry( class SolarEdgeSensorFactory: """Factory which creates sensors based on the sensor_key.""" - def __init__( - self, hass: HomeAssistant, platform_name: str, site_id: str, api: Solaredge - ) -> None: + def __init__(self, hass: HomeAssistant, site_id: str, api: Solaredge) -> None: """Initialize the factory.""" - self.platform_name = platform_name details = SolarEdgeDetailsDataService(hass, api, site_id) overview = SolarEdgeOverviewDataService(hass, api, site_id) @@ -294,7 +290,7 @@ class SolarEdgeSensorFactory: """Create and return a sensor based on the sensor_key.""" sensor_class, service = self.services[sensor_type.key] - return sensor_class(self.platform_name, sensor_type, service) + return sensor_class(sensor_type, service) class SolarEdgeSensorEntity( @@ -302,21 +298,22 @@ class SolarEdgeSensorEntity( ): """Abstract class for a solaredge sensor.""" + _attr_has_entity_name = True + entity_description: SolarEdgeSensorEntityDescription def __init__( self, - platform_name: str, description: SolarEdgeSensorEntityDescription, data_service: SolarEdgeDataService, ) -> None: """Initialize the sensor.""" super().__init__(data_service.coordinator) - self.platform_name = platform_name self.entity_description = description self.data_service = data_service - - self._attr_name = f"{platform_name} ({description.name})" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, data_service.site_id)}, manufacturer="SolarEdge" + ) @property def unique_id(self) -> str | None: @@ -375,12 +372,11 @@ class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): def __init__( self, - platform_name: str, sensor_type: SolarEdgeSensorEntityDescription, data_service: SolarEdgeEnergyDetailsService, ) -> None: """Initialize the power flow sensor.""" - super().__init__(platform_name, sensor_type, data_service) + super().__init__(sensor_type, data_service) self._attr_native_unit_of_measurement = data_service.unit @@ -402,12 +398,11 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): def __init__( self, - platform_name: str, description: SolarEdgeSensorEntityDescription, data_service: SolarEdgePowerFlowDataService, ) -> None: """Initialize the power flow sensor.""" - super().__init__(platform_name, description, data_service) + super().__init__(description, data_service) self._attr_native_unit_of_measurement = data_service.unit diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index b6f258b0dc8..2b626987546 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -19,5 +19,72 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "lifetime_energy": { + "name": "Lifetime energy" + }, + "energy_this_year": { + "name": "Energy this year" + }, + "energy_this_month": { + "name": "Energy this month" + }, + "energy_today": { + "name": "Energy today" + }, + "current_power": { + "name": "Current power" + }, + "site_details": { + "name": "Site details" + }, + "meters": { + "name": "Meters" + }, + "sensors": { + "name": "Sensors" + }, + "gateways": { + "name": "Gateways" + }, + "batteries": { + "name": "Batteries" + }, + "inverters": { + "name": "Inverters" + }, + "power_consumption": { + "name": "Power consumption" + }, + "solar_power": { + "name": "Solar power" + }, + "grid_power": { + "name": "Grid power" + }, + "storage_power": { + "name": "Stored power" + }, + "purchased_energy": { + "name": "Imported energy" + }, + "production_energy": { + "name": "Produced energy" + }, + "consumption_energy": { + "name": "Consumed energy" + }, + "selfconsumption_energy": { + "name": "Self-consumed energy" + }, + "feedin_energy": { + "name": "Exported energy" + }, + "storage_level": { + "name": "Storage level" + } + } } } diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index fce34bde80a..5d36da862ca 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -2,7 +2,7 @@ "domain": "sonos", "name": "Sonos", "after_dependencies": ["plex", "spotify", "zeroconf", "media_source"], - "codeowners": ["@cgtobi", "@jjlawren"], + "codeowners": ["@jjlawren"], "config_flow": true, "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 08f2b08f4df..49caafcc774 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -629,7 +629,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): soco.play_uri(item.get_uri()) return try: - playlists = soco.get_sonos_playlists() + playlists = soco.get_sonos_playlists(complete_result=True) playlist = next(p for p in playlists if p.title == media_id) except StopIteration: _LOGGER.error('Could not find a Sonos playlist named "%s"', media_id) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index fa5c0dd7095..831b64f7056 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -3,7 +3,6 @@ from __future__ import annotations from functools import partial import logging -import re from typing import Any from libsoundtouch.device import SoundTouchDevice @@ -250,7 +249,7 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): ) -> None: """Play a piece of media.""" _LOGGER.debug("Starting media with media_id: %s", media_id) - if re.match(r"http?://", str(media_id)): + if str(media_id).lower().startswith("http://"): # no https support # URL _LOGGER.debug("Playing URL %s", str(media_id)) self._device.play_url(str(media_id)) diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index bd0a6d30369..e00b1f8e402 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -32,7 +32,6 @@ from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) -NONE_SENTINEL = "none" OPTIONS_SCHEMA: vol.Schema = vol.Schema( { @@ -51,32 +50,24 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema( vol.Optional( CONF_VALUE_TEMPLATE, ): selector.TemplateSelector(), - vol.Optional( - CONF_DEVICE_CLASS, - default=NONE_SENTINEL, - ): selector.SelectSelector( + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( - options=[NONE_SENTINEL] - + sorted( - [ - cls.value - for cls in SensorDeviceClass - if cls != SensorDeviceClass.ENUM - ] - ), + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="device_class", + sort=True, ) ), - vol.Optional( - CONF_STATE_CLASS, - default=NONE_SENTINEL, - ): selector.SelectSelector( + vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( - options=[NONE_SENTINEL] - + sorted([cls.value for cls in SensorStateClass]), + options=[cls.value for cls in SensorStateClass], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="state_class", + sort=True, ) ), } @@ -179,9 +170,9 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): options[CONF_UNIT_OF_MEASUREMENT] = uom if value_template := user_input.get(CONF_VALUE_TEMPLATE): options[CONF_VALUE_TEMPLATE] = value_template - if (device_class := user_input[CONF_DEVICE_CLASS]) != NONE_SENTINEL: + if device_class := user_input.get(CONF_DEVICE_CLASS): options[CONF_DEVICE_CLASS] = device_class - if (state_class := user_input[CONF_STATE_CLASS]) != NONE_SENTINEL: + if state_class := user_input.get(CONF_STATE_CLASS): options[CONF_STATE_CLASS] = state_class if db_url_for_validation != get_instance(self.hass).db_url: options[CONF_DB_URL] = db_url_for_validation @@ -248,9 +239,9 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): options[CONF_UNIT_OF_MEASUREMENT] = uom if value_template := user_input.get(CONF_VALUE_TEMPLATE): options[CONF_VALUE_TEMPLATE] = value_template - if (device_class := user_input[CONF_DEVICE_CLASS]) != NONE_SENTINEL: + if device_class := user_input.get(CONF_DEVICE_CLASS): options[CONF_DEVICE_CLASS] = device_class - if (state_class := user_input[CONF_STATE_CLASS]) != NONE_SENTINEL: + if state_class := user_input.get(CONF_STATE_CLASS): options[CONF_STATE_CLASS] = state_class if db_url_for_validation != get_instance(self.hass).db_url: options[CONF_DB_URL] = db_url_for_validation diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 7424807c804..e570f6bac0b 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.21"] + "requirements": ["SQLAlchemy==2.0.22"] } diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index 9ac8bd22027..b4bb73d4b99 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -38,7 +38,7 @@ "init": { "data": { "db_url": "[%key:component::sql::config::step::user::data::db_url%]", - "name": "[%key:component::sql::config::step::user::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "query": "[%key:component::sql::config::step::user::data::query%]", "column": "[%key:component::sql::config::step::user::data::column%]", "unit_of_measurement": "[%key:component::sql::config::step::user::data::unit_of_measurement%]", @@ -67,7 +67,6 @@ "selector": { "device_class": { "options": { - "none": "No device class", "date": "[%key:component::sensor::entity_component::date::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", @@ -110,7 +109,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", @@ -121,7 +120,6 @@ }, "state_class": { "options": { - "none": "No state class", "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index c719afa968d..b8733dd2435 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["starlink-grpc-core==1.1.2"] + "requirements": ["starlink-grpc-core==1.1.3"] } diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index e86a4741080..90cb80a9642 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable import contextlib from datetime import datetime, timedelta import logging +import math import statistics from typing import Any, cast @@ -82,6 +83,7 @@ STAT_DISTANCE_95P = "distance_95_percent_of_values" STAT_DISTANCE_99P = "distance_99_percent_of_values" STAT_DISTANCE_ABSOLUTE = "distance_absolute" STAT_MEAN = "mean" +STAT_MEAN_CIRCULAR = "mean_circular" STAT_MEDIAN = "median" STAT_NOISINESS = "noisiness" STAT_PERCENTILE = "percentile" @@ -111,6 +113,7 @@ STATS_NUMERIC_SUPPORT = { STAT_DISTANCE_99P, STAT_DISTANCE_ABSOLUTE, STAT_MEAN, + STAT_MEAN_CIRCULAR, STAT_MEDIAN, STAT_NOISINESS, STAT_PERCENTILE, @@ -160,6 +163,7 @@ STATS_NUMERIC_RETAIN_UNIT = { STAT_DISTANCE_99P, STAT_DISTANCE_ABSOLUTE, STAT_MEAN, + STAT_MEAN_CIRCULAR, STAT_MEDIAN, STAT_NOISINESS, STAT_PERCENTILE, @@ -681,6 +685,13 @@ class StatisticsSensor(SensorEntity): return statistics.mean(self.states) return None + def _stat_mean_circular(self) -> StateType: + if len(self.states) > 0: + sin_sum = sum(math.sin(math.radians(x)) for x in self.states) + cos_sum = sum(math.cos(math.radians(x)) for x in self.states) + return (math.degrees(math.atan2(sin_sum, cos_sum)) + 360) % 360 + return None + def _stat_median(self) -> StateType: if len(self.states) > 0: return statistics.median(self.states) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 6b8e6c44a1c..5768f886adb 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -4,13 +4,13 @@ from __future__ import annotations import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable +from dataclasses import dataclass, field import datetime from enum import IntEnum import logging from typing import TYPE_CHECKING, Any from aiohttp import web -import attr import numpy as np from homeassistant.components.http.view import HomeAssistantView @@ -50,15 +50,15 @@ class Orientation(IntEnum): ROTATE_RIGHT = 8 -@attr.s(slots=True) +@dataclass(slots=True) class StreamSettings: """Stream settings.""" - ll_hls: bool = attr.ib() - min_segment_duration: float = attr.ib() - part_target_duration: float = attr.ib() - hls_advance_part_limit: int = attr.ib() - hls_part_timeout: float = attr.ib() + ll_hls: bool + min_segment_duration: float + part_target_duration: float + hls_advance_part_limit: int + hls_part_timeout: float STREAM_SETTINGS_NON_LL_HLS = StreamSettings( @@ -70,39 +70,39 @@ STREAM_SETTINGS_NON_LL_HLS = StreamSettings( ) -@attr.s(slots=True) +@dataclass(slots=True) class Part: """Represent a segment part.""" - duration: float = attr.ib() - has_keyframe: bool = attr.ib() + duration: float + has_keyframe: bool # video data (moof+mdat) - data: bytes = attr.ib() + data: bytes -@attr.s(slots=True) +@dataclass(slots=True) class Segment: """Represent a segment.""" - sequence: int = attr.ib() + sequence: int # the init of the mp4 the segment is based on - init: bytes = attr.ib() + init: bytes # For detecting discontinuities across stream restarts - stream_id: int = attr.ib() - start_time: datetime.datetime = attr.ib() - _stream_outputs: Iterable[StreamOutput] = attr.ib() - duration: float = attr.ib(default=0) - parts: list[Part] = attr.ib(factory=list) + stream_id: int + start_time: datetime.datetime + _stream_outputs: Iterable[StreamOutput] + duration: float = 0 + parts: list[Part] = field(default_factory=list) # Store text of this segment's hls playlist for reuse # Use list[str] for easy appends - hls_playlist_template: list[str] = attr.ib(factory=list) - hls_playlist_parts: list[str] = attr.ib(factory=list) + hls_playlist_template: list[str] = field(default_factory=list) + hls_playlist_parts: list[str] = field(default_factory=list) # Number of playlist parts rendered so far - hls_num_parts_rendered: int = attr.ib(default=0) + hls_num_parts_rendered: int = 0 # Set to true when all the parts are rendered - hls_playlist_complete: bool = attr.ib(default=False) + hls_playlist_complete: bool = False - def __attrs_post_init__(self) -> None: + def __post_init__(self) -> None: """Run after init.""" for output in self._stream_outputs: output.put(self) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index cc4970c8a5e..3d27637c989 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -4,13 +4,13 @@ from __future__ import annotations from collections import defaultdict, deque from collections.abc import Callable, Generator, Iterator, Mapping import contextlib +from dataclasses import fields import datetime from io import SEEK_END, BytesIO import logging from threading import Event from typing import Any, Self, cast -import attr import av from homeassistant.core import HomeAssistant @@ -283,7 +283,7 @@ class StreamMuxer: init=read_init(self._memory_file), # Fetch the latest StreamOutputs, which may have changed since the # worker started. - stream_outputs=self._stream_state.outputs, + _stream_outputs=self._stream_state.outputs, start_time=self._start_time, ) self._memory_file_pos = self._memory_file.tell() @@ -537,7 +537,7 @@ def stream_worker( audio_stream = None # Disable ll-hls for hls inputs if container.format.name == "hls": - for field in attr.fields(StreamSettings): + for field in fields(StreamSettings): setattr( stream_settings, field.name, diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index b1730a09357..c856a4817c9 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -26,6 +26,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_suggest_report_issue from homeassistant.util import dt as dt_util, language as language_util from .const import ( @@ -264,7 +265,7 @@ class SpeechToTextView(HomeAssistantView): raise HTTPBadRequest(text=str(err)) from err if not provider_entity: - stt_provider = self._get_provider(provider) + stt_provider = self._get_provider(hass, provider) # Check format if not stt_provider.check_metadata(metadata): @@ -297,7 +298,7 @@ class SpeechToTextView(HomeAssistantView): raise HTTPNotFound() if not provider_entity: - stt_provider = self._get_provider(provider) + stt_provider = self._get_provider(hass, provider) return self.json( { @@ -321,7 +322,7 @@ class SpeechToTextView(HomeAssistantView): } ) - def _get_provider(self, provider: str) -> Provider: + def _get_provider(self, hass: HomeAssistant, provider: str) -> Provider: """Get provider. Method for legacy providers. @@ -331,7 +332,7 @@ class SpeechToTextView(HomeAssistantView): if not self._legacy_provider_reported: self._legacy_provider_reported = True - report_issue = self._suggest_report_issue(provider, stt_provider) + report_issue = self._suggest_report_issue(hass, provider, stt_provider) # This should raise in Home Assistant Core 2023.9 _LOGGER.warning( "Provider %s (%s) is using a legacy implementation, " @@ -344,19 +345,13 @@ class SpeechToTextView(HomeAssistantView): return stt_provider - def _suggest_report_issue(self, provider: str, provider_instance: object) -> str: + def _suggest_report_issue( + self, hass: HomeAssistant, 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 + return async_suggest_report_issue( + hass, integration_domain=provider, module=type(provider_instance).__module__ + ) def _metadata_from_header(request: web.Request) -> SpeechMetadata: diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 9fae6ca9f73..0c4367c77c8 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.6"] + "requirements": ["subarulink==0.7.8"] } diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index f83564bbac3..0f867f9b7c4 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -108,6 +108,14 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, signal=SIGNAL_POSITION_CHANGED, ), + SunSensorEntityDescription( + key="solar_rising", + translation_key="solar_rising", + icon="mdi:sun-clock", + value_fn=lambda data: data.rising, + entity_registry_enabled_default=False, + signal=SIGNAL_EVENTS_CHANGED, + ), ) diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index 3d0374f1de0..eb538eedf09 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -28,7 +28,8 @@ "next_rising": { "name": "Next rising" }, "next_setting": { "name": "Next setting" }, "solar_azimuth": { "name": "Solar azimuth" }, - "solar_elevation": { "name": "Solar elevation" } + "solar_elevation": { "name": "Solar elevation" }, + "solar_rising": { "name": "Solar rising" } } } } diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index cc3a5a4ed0c..7f2857395b8 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -1,4 +1,4 @@ -"""Support for Supla cover - curtains, rollershutters, entry gate etc.""" +"""Support for SUPLA covers - curtains, rollershutters, entry gate etc.""" from __future__ import annotations import logging @@ -26,7 +26,7 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Supla covers.""" + """Set up the SUPLA covers.""" if discovery_info is None: return @@ -59,7 +59,7 @@ async def async_setup_platform( class SuplaCoverEntity(SuplaEntity, CoverEntity): - """Representation of a Supla Cover.""" + """Representation of a SUPLA Cover.""" @property def current_cover_position(self) -> int | None: @@ -93,7 +93,7 @@ class SuplaCoverEntity(SuplaEntity, CoverEntity): class SuplaDoorEntity(SuplaEntity, CoverEntity): - """Representation of a Supla door.""" + """Representation of a SUPLA door.""" _attr_device_class = CoverDeviceClass.GARAGE diff --git a/homeassistant/components/supla/entity.py b/homeassistant/components/supla/entity.py index ae0a627b538..244048973fa 100644 --- a/homeassistant/components/supla/entity.py +++ b/homeassistant/components/supla/entity.py @@ -1,4 +1,4 @@ -"""Base class for Supla channels.""" +"""Base class for SUPLA channels.""" from __future__ import annotations import logging @@ -9,7 +9,7 @@ _LOGGER = logging.getLogger(__name__) class SuplaEntity(CoordinatorEntity): - """Base class of a Supla Channel (an equivalent of HA's Entity).""" + """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.""" @@ -49,7 +49,7 @@ class SuplaEntity(CoordinatorEntity): """Run server action. Actions are currently hardcoded in components. - Supla's API enables autodiscovery + SUPLA's API enables autodiscovery """ _LOGGER.debug( "Executing action %s on channel %d, params: %s", diff --git a/homeassistant/components/supla/manifest.json b/homeassistant/components/supla/manifest.json index 6611c0d795b..6927c92c6e1 100644 --- a/homeassistant/components/supla/manifest.json +++ b/homeassistant/components/supla/manifest.json @@ -1,6 +1,6 @@ { "domain": "supla", - "name": "Supla", + "name": "SUPLA", "codeowners": ["@mwegrzynek"], "documentation": "https://www.home-assistant.io/integrations/supla", "iot_class": "cloud_polling", diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py index b270f4300e1..d904455a3fe 100644 --- a/homeassistant/components/supla/switch.py +++ b/homeassistant/components/supla/switch.py @@ -1,4 +1,4 @@ -"""Support for Supla switch.""" +"""Support for SUPLA switch.""" from __future__ import annotations import logging @@ -22,7 +22,7 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Supla switches.""" + """Set up the SUPLA switches.""" if discovery_info is None: return @@ -44,7 +44,7 @@ async def async_setup_platform( class SuplaSwitchEntity(SuplaEntity, SwitchEntity): - """Representation of a Supla Switch.""" + """Representation of a SUPLA Switch.""" async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index b50709ed76f..0663384fe2c 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -25,7 +25,7 @@ } }, "switch": { - "name": "[%key:component::switch::entity_component::_::name%]" + "name": "[%key:component::switch::title%]" }, "outlet": { "name": "Outlet" diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index cf711fcc431..8d3b2443b18 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -1,27 +1,28 @@ """The SwitchBot via API integration.""" from asyncio import gather -from dataclasses import dataclass +from dataclasses import dataclass, field from logging import getLogger from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] @dataclass class SwitchbotDevices: """Switchbot devices data.""" - switches: list[Device | Remote] + climates: list[Remote] = field(default_factory=list) + switches: list[Device | Remote] = field(default_factory=list) @dataclass @@ -32,6 +33,47 @@ class SwitchbotCloudData: devices: SwitchbotDevices +@callback +def prepare_device( + hass: HomeAssistant, + api: SwitchBotAPI, + device: Device | Remote, + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> tuple[Device | Remote, SwitchBotCoordinator]: + """Instantiate coordinator and adds to list for gathering.""" + coordinator = coordinators_by_id.setdefault( + device.device_id, SwitchBotCoordinator(hass, api, device) + ) + return (device, coordinator) + + +@callback +def make_device_data( + hass: HomeAssistant, + api: SwitchBotAPI, + devices: list[Device | Remote], + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> SwitchbotDevices: + """Make device data.""" + devices_data = SwitchbotDevices() + for device in devices: + if isinstance(device, Remote) and device.device_type.endswith( + "Air Conditioner" + ): + devices_data.climates.append( + prepare_device(hass, api, device, coordinators_by_id) + ) + if ( + isinstance(device, Device) + and device.device_type.startswith("Plug") + or isinstance(device, Remote) + ): + devices_data.switches.append( + prepare_device(hass, api, device, coordinators_by_id) + ) + return devices_data + + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" token = config.data[CONF_API_TOKEN] @@ -48,27 +90,14 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: except CannotConnect as ex: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) - devices_and_coordinators = [ - (device, SwitchBotCoordinator(hass, api, device)) for device in devices - ] + coordinators_by_id: dict[str, SwitchBotCoordinator] = {} hass.data.setdefault(DOMAIN, {}) - data = SwitchbotCloudData( - api=api, - devices=SwitchbotDevices( - switches=[ - (device, coordinator) - for device, coordinator in devices_and_coordinators - if isinstance(device, Device) - and device.device_type.startswith("Plug") - or isinstance(device, Remote) - ], - ), + hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( + api=api, devices=make_device_data(hass, api, devices, coordinators_by_id) ) - hass.data[DOMAIN][config.entry_id] = data - _LOGGER.debug("Switches: %s", data.devices.switches) await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) await gather( - *[coordinator.async_refresh() for _, coordinator in devices_and_coordinators] + *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()] ) return True diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py new file mode 100644 index 00000000000..803669c806d --- /dev/null +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -0,0 +1,120 @@ +"""Support for SwitchBot Air Conditioner remotes.""" + +from typing import Any + +from switchbot_api import AirConditionerCommands + +import homeassistant.components.climate as FanState +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + +_SWITCHBOT_HVAC_MODES: dict[HVACMode, int] = { + HVACMode.HEAT_COOL: 1, + HVACMode.COOL: 2, + HVACMode.DRY: 3, + HVACMode.FAN_ONLY: 4, + HVACMode.HEAT: 5, +} + +_DEFAULT_SWITCHBOT_HVAC_MODE = _SWITCHBOT_HVAC_MODES[HVACMode.FAN_ONLY] + +_SWITCHBOT_FAN_MODES: dict[str, int] = { + FanState.FAN_AUTO: 1, + FanState.FAN_LOW: 2, + FanState.FAN_MEDIUM: 3, + FanState.FAN_HIGH: 4, +} + +_DEFAULT_SWITCHBOT_FAN_MODE = _SWITCHBOT_FAN_MODES[FanState.FAN_AUTO] + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudAirConditionner(data.api, device, coordinator) + for device, coordinator in data.devices.climates + ) + + +class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): + """Representation of a SwitchBot air conditionner. + + As it is an IR device, we don't know the actual state. + """ + + _attr_assumed_state = True + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_fan_modes = [ + FanState.FAN_AUTO, + FanState.FAN_LOW, + FanState.FAN_MEDIUM, + FanState.FAN_HIGH, + ] + _attr_fan_mode = FanState.FAN_AUTO + _attr_hvac_modes = [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + ] + _attr_hvac_mode = HVACMode.FAN_ONLY + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature = 21 + _attr_name = None + + async def _do_send_command( + self, + hvac_mode: HVACMode | None = None, + fan_mode: str | None = None, + temperature: float | None = None, + ) -> None: + new_temperature = temperature or self._attr_target_temperature + new_mode = _SWITCHBOT_HVAC_MODES.get( + hvac_mode or self._attr_hvac_mode, _DEFAULT_SWITCHBOT_HVAC_MODE + ) + new_fan_speed = _SWITCHBOT_FAN_MODES.get( + fan_mode or self._attr_fan_mode, _DEFAULT_SWITCHBOT_FAN_MODE + ) + await self.send_command( + AirConditionerCommands.SET_ALL, + parameters=f"{new_temperature},{new_mode},{new_fan_speed},on", + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set target hvac mode.""" + await self._do_send_command(hvac_mode=hvac_mode) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set target fan mode.""" + await self._do_send_command(fan_mode=fan_mode) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self._do_send_command(temperature=temperature) + self._attr_target_temperature = temperature + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 0451217ca5f..9a4e4fbe196 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==1.1.0"] + "requirements": ["switchbot-api==1.2.1"] } diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index c63b1713b8d..4f2cdc22ba9 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -7,7 +7,6 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType from . import SwitchbotCloudData from .const import DOMAIN @@ -19,7 +18,6 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index d13f5bcbdde..90a6f0659ef 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -46,6 +46,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.MEDIA_PLAYER, Platform.NOTIFY, Platform.SENSOR, ] diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index c71ee86c920..77ff953b67d 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -8,6 +8,7 @@ MODULES = [ "disk", "display", "gpu", + "media", "memory", "system", ] diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 145e01ed29a..a4b016d49bd 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -19,6 +19,7 @@ from systembridgeconnector.models.disk import Disk from systembridgeconnector.models.display import Display from systembridgeconnector.models.get_data import GetData from systembridgeconnector.models.gpu import Gpu +from systembridgeconnector.models.media import Media from systembridgeconnector.models.media_directories import MediaDirectories from systembridgeconnector.models.media_files import File as MediaFile, MediaFiles from systembridgeconnector.models.media_get_file import MediaGetFile @@ -50,6 +51,7 @@ class SystemBridgeCoordinatorData(BaseModel): disk: Disk = None display: Display = None gpu: Gpu = None + media: Media = None memory: Memory = None system: System = None diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py new file mode 100644 index 00000000000..088c57573f1 --- /dev/null +++ b/homeassistant/components/system_bridge/media_player.py @@ -0,0 +1,268 @@ +"""Support for System Bridge media players.""" +from __future__ import annotations + +import datetime as dt +from typing import Final + +from systembridgeconnector.models.media_control import ( + Action as MediaAction, + MediaControl, +) + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityDescription, + MediaPlayerEntityFeature, + MediaPlayerState, + RepeatMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SystemBridgeEntity +from .const import DOMAIN +from .coordinator import SystemBridgeCoordinatorData, SystemBridgeDataUpdateCoordinator + +STATUS_CHANGING: Final[str] = "CHANGING" +STATUS_STOPPED: Final[str] = "STOPPED" +STATUS_PLAYING: Final[str] = "PLAYING" +STATUS_PAUSED: Final[str] = "PAUSED" + +REPEAT_NONE: Final[str] = "NONE" +REPEAT_TRACK: Final[str] = "TRACK" +REPEAT_LIST: Final[str] = "LIST" + +MEDIA_STATUS_MAP: Final[dict[str, MediaPlayerState]] = { + STATUS_CHANGING: MediaPlayerState.IDLE, + STATUS_STOPPED: MediaPlayerState.IDLE, + STATUS_PLAYING: MediaPlayerState.PLAYING, + STATUS_PAUSED: MediaPlayerState.PAUSED, +} + +MEDIA_REPEAT_MAP: Final[dict[str, RepeatMode]] = { + REPEAT_NONE: RepeatMode.OFF, + REPEAT_TRACK: RepeatMode.ONE, + REPEAT_LIST: RepeatMode.ALL, +} + +MEDIA_SET_REPEAT_MAP: Final[dict[RepeatMode, int]] = { + RepeatMode.OFF: 0, + RepeatMode.ONE: 1, + RepeatMode.ALL: 2, +} + +MEDIA_PLAYER_DESCRIPTION: Final[ + MediaPlayerEntityDescription +] = MediaPlayerEntityDescription( + key="media", + translation_key="media", + icon="mdi:volume-high", + device_class=MediaPlayerDeviceClass.RECEIVER, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up System Bridge media players based on a config entry.""" + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + data = coordinator.data + + if data.media is not None: + async_add_entities( + [ + SystemBridgeMediaPlayer( + coordinator, + MEDIA_PLAYER_DESCRIPTION, + entry.data[CONF_PORT], + ) + ] + ) + + +class SystemBridgeMediaPlayer(SystemBridgeEntity, MediaPlayerEntity): + """Define a System Bridge media player.""" + + entity_description: MediaPlayerEntityDescription + + def __init__( + self, + coordinator: SystemBridgeDataUpdateCoordinator, + description: MediaPlayerEntityDescription, + api_port: int, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + api_port, + description.key, + ) + self.entity_description = description + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.data.media is not None + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Flag media player features that are supported.""" + features = ( + MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.SHUFFLE_SET + ) + + data = self._systembridge_data + if data.media.is_previous_enabled: + features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + if data.media.is_next_enabled: + features |= MediaPlayerEntityFeature.NEXT_TRACK + if data.media.is_pause_enabled: + features |= MediaPlayerEntityFeature.PAUSE + if data.media.is_play_enabled: + features |= MediaPlayerEntityFeature.PLAY + if data.media.is_stop_enabled: + features |= MediaPlayerEntityFeature.STOP + + return features + + @property + def _systembridge_data(self) -> SystemBridgeCoordinatorData: + """Return data for the entity.""" + return self.coordinator.data + + @property + def state(self) -> MediaPlayerState | None: + """State of the player.""" + if self._systembridge_data.media.status is None: + return None + return MEDIA_STATUS_MAP.get( + self._systembridge_data.media.status, + MediaPlayerState.IDLE, + ) + + @property + def media_duration(self) -> int | None: + """Duration of current playing media in seconds.""" + if self._systembridge_data.media.duration is None: + return None + return int(self._systembridge_data.media.duration) + + @property + def media_position(self) -> int | None: + """Position of current playing media in seconds.""" + if self._systembridge_data.media.position is None: + return None + return int(self._systembridge_data.media.position) + + @property + def media_position_updated_at(self) -> dt.datetime | None: + """When was the position of the current playing media valid.""" + if self._systembridge_data.media.updated_at is None: + return None + return dt.datetime.fromtimestamp(self._systembridge_data.media.updated_at) + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + return self._systembridge_data.media.title + + @property + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + return self._systembridge_data.media.artist + + @property + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + return self._systembridge_data.media.album_title + + @property + def media_album_artist(self) -> str | None: + """Album artist of current playing media, music track only.""" + return self._systembridge_data.media.album_artist + + @property + def media_track(self) -> int | None: + """Track number of current playing media, music track only.""" + return self._systembridge_data.media.track_number + + @property + def shuffle(self) -> bool | None: + """Boolean if shuffle is enabled.""" + return self._systembridge_data.media.shuffle + + @property + def repeat(self) -> RepeatMode | None: + """Return current repeat mode.""" + if self._systembridge_data.media.repeat is None: + return RepeatMode.OFF + return MEDIA_REPEAT_MAP.get(self._systembridge_data.media.repeat) + + async def async_media_play(self) -> None: + """Send play command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.play, + ) + ) + + async def async_media_pause(self) -> None: + """Send pause command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.pause, + ) + ) + + async def async_media_stop(self) -> None: + """Send stop command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.stop, + ) + ) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.previous, + ) + ) + + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.next, + ) + ) + + async def async_set_shuffle( + self, + shuffle: bool, + ) -> None: + """Enable/disable shuffle mode.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.shuffle, + value=shuffle, + ) + ) + + async def async_set_repeat( + self, + repeat: RepeatMode, + ) -> None: + """Set repeat mode.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.repeat, + value=MEDIA_SET_REPEAT_MAP.get(repeat), + ) + ) diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index e8565568d20..4df539f11d4 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -29,6 +29,11 @@ } }, "entity": { + "media_player": { + "media": { + "name": "Media" + } + }, "sensor": { "boot_time": { "name": "Boot time" diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index e02d0421f8d..3bcbc75d3b7 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.5"] + "requirements": ["psutil==5.9.6"] } diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 9366a18b6fe..d6ae50c33c1 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -119,7 +119,7 @@ TADO_MODES_TO_HA_CURRENT_HVAC_ACTION = { } # These modes will not allow a temp to be set -TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN] +TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_FAN] # # HVAC_MODE_HEAT_COOL is mapped to CONST_MODE_AUTO # This lets tado decide on a temp diff --git a/homeassistant/components/tami4/__init__.py b/homeassistant/components/tami4/__init__.py new file mode 100644 index 00000000000..846f1194930 --- /dev/null +++ b/homeassistant/components/tami4/__init__.py @@ -0,0 +1,46 @@ +"""The Tami4Edge integration.""" +from __future__ import annotations + +from Tami4EdgeAPI import Tami4EdgeAPI, exceptions + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import API, CONF_REFRESH_TOKEN, COORDINATOR, DOMAIN +from .coordinator import Tami4EdgeWaterQualityCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up tami4 from a config entry.""" + refresh_token = entry.data.get(CONF_REFRESH_TOKEN) + + try: + api = await hass.async_add_executor_job(Tami4EdgeAPI, refresh_token) + except exceptions.RefreshTokenExpiredException as ex: + raise ConfigEntryError("API Refresh token expired") from ex + except exceptions.TokenRefreshFailedException as ex: + raise ConfigEntryNotReady("Error connecting to API") from ex + + coordinator = Tami4EdgeWaterQualityCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + API: api, + COORDINATOR: coordinator, + } + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py new file mode 100644 index 00000000000..b36ba9c46c0 --- /dev/null +++ b/homeassistant/components/tami4/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for edge integration.""" +from __future__ import annotations + +import logging +import re +from typing import Any + +from Tami4EdgeAPI import Tami4EdgeAPI, exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv + +from .const import CONF_PHONE, CONF_REFRESH_TOKEN, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_STEP_PHONE_NUMBER_SCHEMA = vol.Schema({vol.Required(CONF_PHONE): cv.string}) + +_STEP_OTP_CODE_SCHEMA = vol.Schema({vol.Required("otp"): cv.string}) +_PHONE_MATCHER = re.compile(r"^(\+?972)?0?(?P\d{8,9})$") + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tami4Edge.""" + + VERSION = 1 + + phone: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the otp request step.""" + errors = {} + if user_input is not None: + phone = user_input[CONF_PHONE].strip() + + try: + if m := _PHONE_MATCHER.match(phone): + self.phone = f"+972{m.group('number')}" + else: + raise InvalidPhoneNumber + await self.hass.async_add_executor_job( + Tami4EdgeAPI.request_otp, self.phone + ) + except InvalidPhoneNumber: + errors["base"] = "invalid_phone" + except exceptions.Tami4EdgeAPIException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self.async_step_otp() + + return self.async_show_form( + step_id="user", data_schema=_STEP_PHONE_NUMBER_SCHEMA, errors=errors + ) + + async def async_step_otp( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the otp submission step.""" + errors = {} + if user_input is not None: + otp = user_input["otp"] + try: + refresh_token = await self.hass.async_add_executor_job( + Tami4EdgeAPI.submit_otp, self.phone, otp + ) + api = await self.hass.async_add_executor_job( + Tami4EdgeAPI, refresh_token + ) + except exceptions.OTPFailedException: + errors["base"] = "invalid_auth" + except exceptions.Tami4EdgeAPIException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=api.device.name, data={CONF_REFRESH_TOKEN: refresh_token} + ) + + return self.async_show_form( + step_id="otp", data_schema=_STEP_OTP_CODE_SCHEMA, errors=errors + ) + + +class InvalidPhoneNumber(HomeAssistantError): + """Error to indicate that the phone number is invalid.""" diff --git a/homeassistant/components/tami4/const.py b/homeassistant/components/tami4/const.py new file mode 100644 index 00000000000..4e64bdf896d --- /dev/null +++ b/homeassistant/components/tami4/const.py @@ -0,0 +1,6 @@ +"""Constants for tami4 component.""" +DOMAIN = "tami4" +CONF_PHONE = "phone" +CONF_REFRESH_TOKEN = "refresh_token" +API = "api" +COORDINATOR = "coordinator" diff --git a/homeassistant/components/tami4/coordinator.py b/homeassistant/components/tami4/coordinator.py new file mode 100644 index 00000000000..ef57af71012 --- /dev/null +++ b/homeassistant/components/tami4/coordinator.py @@ -0,0 +1,61 @@ +"""Water quality coordinator for Tami4Edge.""" +from dataclasses import dataclass +from datetime import date, timedelta +import logging + +from Tami4EdgeAPI import Tami4EdgeAPI, exceptions +from Tami4EdgeAPI.water_quality import WaterQuality + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class FlattenedWaterQuality: + """Flattened WaterQuality dataclass.""" + + uv_last_replacement: date + uv_upcoming_replacement: date + uv_status: str + filter_last_replacement: date + filter_upcoming_replacement: date + filter_status: str + filter_litters_passed: float + + def __init__(self, water_quality: WaterQuality) -> None: + """Flatten WaterQuality dataclass.""" + + self.uv_last_replacement = water_quality.uv.last_replacement + self.uv_upcoming_replacement = water_quality.uv.upcoming_replacement + self.uv_status = water_quality.uv.status + self.filter_last_replacement = water_quality.filter.last_replacement + self.filter_upcoming_replacement = water_quality.filter.upcoming_replacement + self.filter_status = water_quality.filter.status + self.filter_litters_passed = water_quality.filter.milli_litters_passed / 1000 + + +class Tami4EdgeWaterQualityCoordinator(DataUpdateCoordinator[FlattenedWaterQuality]): + """Tami4Edge water quality coordinator.""" + + def __init__(self, hass: HomeAssistant, api: Tami4EdgeAPI) -> None: + """Initialize the water quality coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Tami4Edge water quality coordinator", + update_interval=timedelta(minutes=60), + ) + self._api = api + + async def _async_update_data(self) -> FlattenedWaterQuality: + """Fetch data from the API endpoint.""" + try: + water_quality = await self.hass.async_add_executor_job( + self._api.get_water_quality + ) + + return FlattenedWaterQuality(water_quality) + except exceptions.APIRequestFailedException as ex: + raise UpdateFailed("Error communicating with API") from ex diff --git a/homeassistant/components/tami4/entity.py b/homeassistant/components/tami4/entity.py new file mode 100644 index 00000000000..50c066b9b6d --- /dev/null +++ b/homeassistant/components/tami4/entity.py @@ -0,0 +1,33 @@ +"""Base entity for Tami4Edge.""" +from __future__ import annotations + +from Tami4EdgeAPI import Tami4EdgeAPI + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from .const import DOMAIN + + +class Tami4EdgeBaseEntity(Entity): + """Base class for Tami4Edge entities.""" + + _attr_has_entity_name = True + + def __init__( + self, api: Tami4EdgeAPI, entity_description: EntityDescription + ) -> None: + """Initialize the Tami4Edge.""" + self._state = None + self._api = api + device_id = api.device.psn + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}_{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer="Stratuss", + name=api.device.name, + model="Tami4", + sw_version=api.device.device_firmware, + suggested_area="Kitchen", + ) diff --git a/homeassistant/components/tami4/manifest.json b/homeassistant/components/tami4/manifest.json new file mode 100644 index 00000000000..49cbf6fe1c6 --- /dev/null +++ b/homeassistant/components/tami4/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "tami4", + "name": "Tami4 Edge / Edge+", + "codeowners": ["@Guy293"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tami4", + "iot_class": "cloud_polling", + "requirements": ["Tami4EdgeAPI==2.1"] +} diff --git a/homeassistant/components/tami4/sensor.py b/homeassistant/components/tami4/sensor.py new file mode 100644 index 00000000000..df271da7309 --- /dev/null +++ b/homeassistant/components/tami4/sensor.py @@ -0,0 +1,118 @@ +"""Sensor entities for Tami4Edge.""" +import logging + +from Tami4EdgeAPI import Tami4EdgeAPI + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfVolume +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import API, COORDINATOR, DOMAIN +from .coordinator import Tami4EdgeWaterQualityCoordinator +from .entity import Tami4EdgeBaseEntity + +_LOGGER = logging.getLogger(__name__) + +ENTITY_DESCRIPTIONS = [ + SensorEntityDescription( + key="uv_last_replacement", + translation_key="uv_last_replacement", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + ), + SensorEntityDescription( + key="uv_upcoming_replacement", + translation_key="uv_upcoming_replacement", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + ), + SensorEntityDescription( + key="uv_status", + translation_key="uv_status", + icon="mdi:clipboard-check-multiple", + ), + SensorEntityDescription( + key="filter_last_replacement", + translation_key="filter_last_replacement", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + ), + SensorEntityDescription( + key="filter_upcoming_replacement", + translation_key="filter_upcoming_replacement", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + ), + SensorEntityDescription( + key="filter_status", + translation_key="filter_status", + icon="mdi:clipboard-check-multiple", + ), + SensorEntityDescription( + key="filter_litters_passed", + translation_key="filter_litters_passed", + icon="mdi:water", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Perform the setup for Tami4Edge.""" + data = hass.data[DOMAIN][entry.entry_id] + api: Tami4EdgeAPI = data[API] + coordinator: Tami4EdgeWaterQualityCoordinator = data[COORDINATOR] + + entities = [] + for entity_description in ENTITY_DESCRIPTIONS: + entities.append( + Tami4EdgeSensorEntity( + coordinator=coordinator, + api=api, + entity_description=entity_description, + ) + ) + + async_add_entities(entities) + + +class Tami4EdgeSensorEntity( + Tami4EdgeBaseEntity, + CoordinatorEntity[Tami4EdgeWaterQualityCoordinator], + SensorEntity, +): + """Representation of the entity.""" + + def __init__( + self, + coordinator: Tami4EdgeWaterQualityCoordinator, + api: Tami4EdgeAPI, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the Tami4Edge sensor entity.""" + Tami4EdgeBaseEntity.__init__(self, api, entity_description) + CoordinatorEntity.__init__(self, coordinator) + self._update_attr() + + def _update_attr(self) -> None: + self._attr_native_value = getattr( + self.coordinator.data, self.entity_description.key + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + self.async_write_ha_state() diff --git a/homeassistant/components/tami4/strings.json b/homeassistant/components/tami4/strings.json new file mode 100644 index 00000000000..9036d92d6f1 --- /dev/null +++ b/homeassistant/components/tami4/strings.json @@ -0,0 +1,54 @@ +{ + "entity": { + "sensor": { + "uv_last_replacement": { + "name": "UV last replacement" + }, + "uv_upcoming_replacement": { + "name": "UV upcoming replacement" + }, + "uv_status": { + "name": "UV status" + }, + "filter_last_replacement": { + "name": "Filter last replacement" + }, + "filter_upcoming_replacement": { + "name": "Filter upcoming replacement" + }, + "filter_status": { + "name": "Filter status" + }, + "filter_litters_passed": { + "name": "Filter water passed" + } + } + }, + "config": { + "step": { + "user": { + "title": "SMS Verification", + "description": "Enter your phone number (same as what you used to register to the tami4 app)", + "data": { + "phone": "Phone Number" + } + }, + "otp": { + "title": "[%key:component::tami4::config::step::user::title%]", + "description": "Enter the code you received via SMS", + "data": { + "otp": "SMS Code" + } + } + }, + "error": { + "invalid_phone": "Invalid phone number, please use the following format: +972xxxxxxxx", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/tank_utility/manifest.json b/homeassistant/components/tank_utility/manifest.json index 3f4d7bbaa15..d73c62fa5ec 100644 --- a/homeassistant/components/tank_utility/manifest.json +++ b/homeassistant/components/tank_utility/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/tank_utility", "iot_class": "cloud_polling", "loggers": ["tank_utility"], - "requirements": ["tank-utility==1.4.1"] + "requirements": ["tank-utility==1.5.0"] } diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 3d56cc7ed33..76677c3813e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -120,6 +120,7 @@ EVENT_TELEGRAM_SENT = "telegram_sent" PARSER_HTML = "html" PARSER_MD = "markdown" +PARSER_MD2 = "markdownv2" DEFAULT_TRUSTED_NETWORKS = [ip_network("149.154.160.0/20"), ip_network("91.108.4.0/22")] @@ -474,7 +475,11 @@ class TelegramNotificationService: self.allowed_chat_ids = allowed_chat_ids self._default_user = self.allowed_chat_ids[0] self._last_message_id = {user: None for user in self.allowed_chat_ids} - self._parsers = {PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN} + self._parsers = { + PARSER_HTML: ParseMode.HTML, + PARSER_MD: ParseMode.MARKDOWN, + PARSER_MD2: ParseMode.MARKDOWN_V2, + } self._parse_mode = self._parsers.get(parser) self.bot = bot self.hass = hass diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index cdb50d55943..94d1eee1b55 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -21,7 +21,7 @@ send_message: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -90,7 +90,7 @@ send_photo: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -217,7 +217,7 @@ send_animation: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -280,7 +280,7 @@ send_video: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -407,7 +407,7 @@ send_document: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -543,7 +543,7 @@ edit_message: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_web_page_preview: selector: boolean: diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 8b94cb66496..c21cffa84b1 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -3,6 +3,8 @@ import datetime as dt from http import HTTPStatus from ipaddress import ip_address import logging +import secrets +import string from telegram import Update from telegram.error import TimedOut @@ -18,11 +20,17 @@ _LOGGER = logging.getLogger(__name__) TELEGRAM_WEBHOOK_URL = "/api/telegram_webhooks" REMOVE_WEBHOOK_URL = "" +SECRET_TOKEN_LENGTH = 32 async def async_setup_platform(hass, bot, config): """Set up the Telegram webhooks platform.""" - pushbot = PushBot(hass, bot, config) + + # Generate an ephemeral secret token + alphabet = string.ascii_letters + string.digits + "-_" + secret_token = "".join(secrets.choice(alphabet) for _ in range(SECRET_TOKEN_LENGTH)) + + pushbot = PushBot(hass, bot, config, secret_token) if not pushbot.webhook_url.startswith("https"): _LOGGER.error("Invalid telegram webhook %s must be https", pushbot.webhook_url) @@ -34,7 +42,13 @@ async def async_setup_platform(hass, bot, config): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, pushbot.deregister_webhook) hass.http.register_view( - PushBotView(hass, bot, pushbot.dispatcher, config[CONF_TRUSTED_NETWORKS]) + PushBotView( + hass, + bot, + pushbot.dispatcher, + config[CONF_TRUSTED_NETWORKS], + secret_token, + ) ) return True @@ -42,10 +56,11 @@ async def async_setup_platform(hass, bot, config): class PushBot(BaseTelegramBotEntity): """Handles all the push/webhook logic and passes telegram updates to `self.handle_update`.""" - def __init__(self, hass, bot, config): + def __init__(self, hass, bot, config, secret_token): """Create Dispatcher before calling super().""" self.bot = bot self.trusted_networks = config[CONF_TRUSTED_NETWORKS] + self.secret_token = secret_token # Dumb dispatcher that just gets our updates to our handler callback (self.handle_update) self.dispatcher = Dispatcher(bot, None) self.dispatcher.add_handler(TypeHandler(Update, self.handle_update)) @@ -61,7 +76,11 @@ class PushBot(BaseTelegramBotEntity): retry_num = 0 while retry_num < 3: try: - return self.bot.set_webhook(self.webhook_url, timeout=5) + return self.bot.set_webhook( + self.webhook_url, + api_kwargs={"secret_token": self.secret_token}, + timeout=5, + ) except TimedOut: retry_num += 1 _LOGGER.warning("Timeout trying to set webhook (retry #%d)", retry_num) @@ -108,12 +127,13 @@ class PushBotView(HomeAssistantView): url = TELEGRAM_WEBHOOK_URL name = "telegram_webhooks" - def __init__(self, hass, bot, dispatcher, trusted_networks): + def __init__(self, hass, bot, dispatcher, trusted_networks, secret_token): """Initialize by storing stuff needed for setting up our webhook endpoint.""" self.hass = hass self.bot = bot self.dispatcher = dispatcher self.trusted_networks = trusted_networks + self.secret_token = secret_token async def post(self, request): """Accept the POST from telegram.""" @@ -121,6 +141,10 @@ class PushBotView(HomeAssistantView): if not any(real_ip in net for net in self.trusted_networks): _LOGGER.warning("Access denied from %s", real_ip) return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) + secret_token_header = request.headers.get("X-Telegram-Bot-Api-Secret-Token") + if secret_token_header is None or self.secret_token != secret_token_header: + _LOGGER.warning("Invalid secret token from %s", real_ip) + return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) try: update_data = await request.json() diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index c361b4c42cc..686c12fa4ba 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -37,8 +37,6 @@ from .const import DOMAIN from .sensor import async_create_preview_sensor from .template_entity import TemplateEntity -NONE_SENTINEL = "none" - def generate_schema(domain: str, flow_type: str) -> dict[vol.Marker, Any]: """Generate schema.""" @@ -48,71 +46,50 @@ def generate_schema(domain: str, flow_type: str) -> dict[vol.Marker, Any]: schema = { vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( - options=[ - NONE_SENTINEL, - *sorted( - [cls.value for cls in BinarySensorDeviceClass], - key=str.casefold, - ), - ], + options=[cls.value for cls in BinarySensorDeviceClass], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="binary_sensor_device_class", + sort=True, ), ) } if domain == Platform.SENSOR: schema = { - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, default=NONE_SENTINEL - ): selector.SelectSelector( + vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector( selector.SelectSelectorConfig( - options=[ - NONE_SENTINEL, - *sorted( - { - str(unit) - for units in DEVICE_CLASS_UNITS.values() - for unit in units - if unit is not None - }, - key=str.casefold, - ), - ], + options=list( + { + str(unit) + for units in DEVICE_CLASS_UNITS.values() + for unit in units + if unit is not None + } + ), mode=selector.SelectSelectorMode.DROPDOWN, translation_key="sensor_unit_of_measurement", custom_value=True, + sort=True, ), ), - vol.Optional( - CONF_DEVICE_CLASS, default=NONE_SENTINEL - ): selector.SelectSelector( + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( options=[ - NONE_SENTINEL, - *sorted( - [ - cls.value - for cls in SensorDeviceClass - if cls != SensorDeviceClass.ENUM - ], - key=str.casefold, - ), + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM ], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="sensor_device_class", + sort=True, ), ), - vol.Optional( - CONF_STATE_CLASS, default=NONE_SENTINEL - ): selector.SelectSelector( + vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( - options=[ - NONE_SENTINEL, - *sorted([cls.value for cls in SensorStateClass]), - ], + options=[cls.value for cls in SensorStateClass], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="sensor_state_class", + sort=True, ), ), } @@ -144,15 +121,6 @@ async def choose_options_step(options: dict[str, Any]) -> str: return cast(str, options["template_type"]) -def _strip_sentinel(options: dict[str, Any]) -> None: - """Convert sentinel to None.""" - for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): - if key not in options: - continue - if options[key] == NONE_SENTINEL: - options.pop(key) - - def _validate_unit(options: dict[str, Any]) -> None: """Validate unit of measurement.""" if ( @@ -208,8 +176,7 @@ def validate_user_input( ]: """Do post validation of user input. - For binary sensors: Strip none-sentinels. - For sensors: Strip none-sentinels and validate unit of measurement. + For sensors: Validate unit of measurement. For all domaines: Set template type. """ @@ -218,8 +185,6 @@ def validate_user_input( user_input: dict[str, Any], ) -> dict[str, Any]: """Add template type to user input.""" - if template_type in (Platform.BINARY_SENSOR, Platform.SENSOR): - _strip_sentinel(user_input) if template_type == Platform.SENSOR: _validate_unit(user_input) _validate_state_class(user_input) @@ -316,7 +281,6 @@ def ws_start_preview( errors[key.schema] = str(ex.msg) if domain == Platform.SENSOR: - _strip_sentinel(user_input) try: _validate_unit(user_input) except vol.Invalid as ex: @@ -386,7 +350,6 @@ def ws_start_preview( ) return - _strip_sentinel(msg["user_input"]) preview_entity = CREATE_PREVIEW_ENTITY[template_type](hass, name, msg["user_input"]) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index a0ee31126cd..19ad9e5ddeb 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -40,7 +40,7 @@ "sensor": { "data": { "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", - "state_class": "[%key:component::template::config::step::sensor::data::state_class%]", + "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", "state": "[%key:component::template::config::step::sensor::data::state%]", "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" }, @@ -51,7 +51,6 @@ "selector": { "binary_sensor_device_class": { "options": { - "none": "[%key:component::template::selector::sensor_device_class::options::none%]", "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", "battery_charging": "[%key:component::binary_sensor::entity_component::battery_charging::name%]", "carbon_monoxide": "[%key:component::binary_sensor::entity_component::carbon_monoxide::name%]", @@ -83,7 +82,6 @@ }, "sensor_device_class": { "options": { - "none": "No device class", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", @@ -126,7 +124,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", @@ -137,7 +135,6 @@ }, "sensor_state_class": { "options": { - "none": "No state class", "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index c8682941e28..39083434e89 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==1.26.0", - "Pillow==10.0.1" + "Pillow==10.1.0" ] } diff --git a/homeassistant/components/thermobeacon/strings.json b/homeassistant/components/thermobeacon/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/thermobeacon/strings.json +++ b/homeassistant/components/thermobeacon/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/threshold/strings.json b/homeassistant/components/threshold/strings.json index 832f3b4f899..fc9ee8fb7bf 100644 --- a/homeassistant/components/threshold/strings.json +++ b/homeassistant/components/threshold/strings.json @@ -26,7 +26,7 @@ "entity_id": "[%key:component::threshold::config::step::user::data::entity_id%]", "hysteresis": "[%key:component::threshold::config::step::user::data::hysteresis%]", "lower": "[%key:component::threshold::config::step::user::data::lower%]", - "name": "[%key:component::threshold::config::step::user::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "upper": "[%key:component::threshold::config::step::user::data::upper%]" } } diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py index 29754ffba4b..1e8cebdd5a6 100644 --- a/homeassistant/components/tile/__init__.py +++ b/homeassistant/components/tile/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency from .const import DOMAIN, LOGGER @@ -106,7 +106,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator_init_tasks.append(coordinator.async_refresh()) - await gather_with_concurrency(DEFAULT_INIT_TASK_LIMIT, *coordinator_init_tasks) + await gather_with_limited_concurrency( + DEFAULT_INIT_TASK_LIMIT, *coordinator_init_tasks + ) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = TileData(coordinators=coordinators, tiles=tiles) diff --git a/homeassistant/components/tilt_ble/strings.json b/homeassistant/components/tilt_ble/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/tilt_ble/strings.json +++ b/homeassistant/components/tilt_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py new file mode 100644 index 00000000000..968256ce3d9 --- /dev/null +++ b/homeassistant/components/todo/__init__.py @@ -0,0 +1,260 @@ +"""The todo integration.""" + +import dataclasses +import datetime +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import frontend, websocket_api +from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, TodoItemStatus, TodoListEntityFeature + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(seconds=60) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Todo entities.""" + component = hass.data[DOMAIN] = EntityComponent[TodoListEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + frontend.async_register_built_in_panel(hass, "todo", "todo", "mdi:clipboard-list") + + websocket_api.async_register_command(hass, websocket_handle_todo_item_list) + websocket_api.async_register_command(hass, websocket_handle_todo_item_move) + + component.async_register_entity_service( + "add_item", + { + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), + }, + _async_add_todo_item, + required_features=[TodoListEntityFeature.CREATE_TODO_ITEM], + ) + component.async_register_entity_service( + "update_item", + vol.All( + cv.make_entity_service_schema( + { + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), + vol.Optional("rename"): vol.All(cv.string, vol.Length(min=1)), + vol.Optional("status"): vol.In( + {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} + ), + } + ), + cv.has_at_least_one_key("rename", "status"), + ), + _async_update_todo_item, + required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM], + ) + component.async_register_entity_service( + "remove_item", + cv.make_entity_service_schema( + { + vol.Required("item"): vol.All(cv.ensure_list, [cv.string]), + } + ), + _async_remove_todo_items, + required_features=[TodoListEntityFeature.DELETE_TODO_ITEM], + ) + + await component.async_setup(config) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[TodoListEntity] = 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[TodoListEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclasses.dataclass +class TodoItem: + """A To-do item in a To-do list.""" + + summary: str | None = None + """The summary that represents the item.""" + + uid: str | None = None + """A unique identifier for the To-do item.""" + + status: TodoItemStatus | None = None + """A status or confirmation of the To-do item.""" + + +class TodoListEntity(Entity): + """An entity that represents a To-do list.""" + + _attr_todo_items: list[TodoItem] | None = None + + @property + def state(self) -> int | None: + """Return the entity state as the count of incomplete items.""" + items = self.todo_items + if items is None: + return None + return sum([item.status == TodoItemStatus.NEEDS_ACTION for item in items]) + + @property + def todo_items(self) -> list[TodoItem] | None: + """Return the To-do items in the To-do list.""" + return self._attr_todo_items + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + raise NotImplementedError() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item in the To-do list.""" + raise NotImplementedError() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item in the To-do list.""" + raise NotImplementedError() + + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Move an item in the To-do list. + + The To-do item with the specified `uid` should be moved to the position + in the list after the specified by `previous_uid` or `None` for the first + position in the To-do list. + """ + raise NotImplementedError() + + +@websocket_api.websocket_command( + { + vol.Required("type"): "todo/item/list", + vol.Required("entity_id"): cv.entity_id, + } +) +@websocket_api.async_response +async def websocket_handle_todo_item_list( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle the list of To-do items in a To-do- list.""" + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + if ( + not (entity_id := msg[CONF_ENTITY_ID]) + or not (entity := component.get_entity(entity_id)) + or not isinstance(entity, TodoListEntity) + ): + connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") + return + + items: list[TodoItem] = entity.todo_items or [] + connection.send_message( + websocket_api.result_message( + msg["id"], {"items": [dataclasses.asdict(item) for item in items]} + ) + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "todo/item/move", + vol.Required("entity_id"): cv.entity_id, + vol.Required("uid"): cv.string, + vol.Optional("previous_uid"): cv.string, + } +) +@websocket_api.async_response +async def websocket_handle_todo_item_move( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle move of a To-do item within a To-do list.""" + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + if not (entity := component.get_entity(msg["entity_id"])): + connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") + return + + if ( + not entity.supported_features + or not entity.supported_features & TodoListEntityFeature.MOVE_TODO_ITEM + ): + connection.send_message( + websocket_api.error_message( + msg["id"], + ERR_NOT_SUPPORTED, + "To-do list does not support To-do item reordering", + ) + ) + return + try: + await entity.async_move_todo_item( + uid=msg["uid"], previous_uid=msg.get("previous_uid") + ) + except HomeAssistantError as ex: + connection.send_error(msg["id"], "failed", str(ex)) + else: + connection.send_result(msg["id"]) + + +def _find_by_uid_or_summary( + value: str, items: list[TodoItem] | None +) -> TodoItem | None: + """Find a To-do List item by uid or summary name.""" + for item in items or (): + if value in (item.uid, item.summary): + return item + return None + + +async def _async_add_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: + """Add an item to the To-do list.""" + await entity.async_create_todo_item( + item=TodoItem(summary=call.data["item"], status=TodoItemStatus.NEEDS_ACTION) + ) + + +async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: + """Update an item in the To-do list.""" + item = call.data["item"] + found = _find_by_uid_or_summary(item, entity.todo_items) + if not found: + raise ValueError(f"Unable to find To-do item '{item}'") + + update_item = TodoItem( + uid=found.uid, summary=call.data.get("rename"), status=call.data.get("status") + ) + + await entity.async_update_todo_item(item=update_item) + + +async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: + """Remove an item in the To-do list.""" + uids = [] + for item in call.data.get("item", []): + found = _find_by_uid_or_summary(item, entity.todo_items) + if not found or not found.uid: + raise ValueError(f"Unable to find To-do item '{item}") + uids.append(found.uid) + await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py new file mode 100644 index 00000000000..5a8a6e54e8f --- /dev/null +++ b/homeassistant/components/todo/const.py @@ -0,0 +1,24 @@ +"""Constants for the To-do integration.""" + +from enum import IntFlag, StrEnum + +DOMAIN = "todo" + + +class TodoListEntityFeature(IntFlag): + """Supported features of the To-do List entity.""" + + CREATE_TODO_ITEM = 1 + DELETE_TODO_ITEM = 2 + UPDATE_TODO_ITEM = 4 + MOVE_TODO_ITEM = 8 + + +class TodoItemStatus(StrEnum): + """Status or confirmation of a To-do List Item. + + This is a subset of the statuses supported in rfc5545. + """ + + NEEDS_ACTION = "needs_action" + COMPLETED = "completed" diff --git a/homeassistant/components/todo/manifest.json b/homeassistant/components/todo/manifest.json new file mode 100644 index 00000000000..8efc93ad4e7 --- /dev/null +++ b/homeassistant/components/todo/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "todo", + "name": "To-do list", + "codeowners": ["@home-assistant/core"], + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/todo", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml new file mode 100644 index 00000000000..1bdb8aca779 --- /dev/null +++ b/homeassistant/components/todo/services.yaml @@ -0,0 +1,47 @@ +add_item: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.CREATE_TODO_ITEM + fields: + item: + required: true + example: "Submit income tax return" + selector: + text: +update_item: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.UPDATE_TODO_ITEM + fields: + item: + required: true + example: "Submit income tax return" + selector: + text: + rename: + example: "Something else" + selector: + text: + status: + example: "needs_action" + selector: + select: + translation_key: status + options: + - needs_action + - completed +remove_item: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.DELETE_TODO_ITEM + fields: + item: + required: true + selector: + text: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json new file mode 100644 index 00000000000..6ba8aaba1a5 --- /dev/null +++ b/homeassistant/components/todo/strings.json @@ -0,0 +1,56 @@ +{ + "title": "To-do list", + "entity_component": { + "_": { + "name": "[%key:component::todo::title%]" + } + }, + "services": { + "add_item": { + "name": "Add to-do list item", + "description": "Add a new to-do list item.", + "fields": { + "item": { + "name": "Item name", + "description": "The name that represents the to-do item." + } + } + }, + "update_item": { + "name": "Update to-do list item", + "description": "Update an existing to-do list item based on its name.", + "fields": { + "item": { + "name": "Item name", + "description": "The name for the to-do list item." + }, + "rename": { + "name": "Rename item", + "description": "The new name of the to-do item" + }, + "status": { + "name": "Set status", + "description": "A status or confirmation of the to-do item." + } + } + }, + "remove_item": { + "name": "Remove a to-do list item", + "description": "Remove an existing to-do list item by its name.", + "fields": { + "item": { + "name": "Item name", + "description": "The name for the to-do list items." + } + } + } + }, + "selector": { + "status": { + "options": { + "needs_action": "Not completed", + "completed": "Completed" + } + } + } +} diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 12b75a40bae..60c40b1c03c 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=1) -PLATFORMS: list[Platform] = [Platform.CALENDAR] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index 6098df40ea0..b8c79210dfb 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -44,7 +44,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await api.get_tasks() except HTTPError as err: if err.response.status_code == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_access_token" + errors["base"] = "invalid_api_key" else: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 123b5d07ed7..442114eb118 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -9,10 +9,12 @@ } }, "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -48,7 +50,7 @@ "description": "The day this task is due, in natural language." }, "due_date_lang": { - "name": "Due data language", + "name": "Due date language", "description": "The language of due_date_string." }, "due_date": { diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py new file mode 100644 index 00000000000..c0d3ec6e2ce --- /dev/null +++ b/homeassistant/components/todoist/todo.py @@ -0,0 +1,111 @@ +"""A todo platform for Todoist.""" + +import asyncio +from typing import cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TodoistCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Todoist todo platform config entry.""" + coordinator: TodoistCoordinator = hass.data[DOMAIN][entry.entry_id] + projects = await coordinator.async_get_projects() + async_add_entities( + TodoistTodoListEntity(coordinator, entry.entry_id, project.id, project.name) + for project in projects + ) + + +class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): + """A Todoist TodoListEntity.""" + + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + def __init__( + self, + coordinator: TodoistCoordinator, + config_entry_id: str, + project_id: str, + project_name: str, + ) -> None: + """Initialize TodoistTodoListEntity.""" + super().__init__(coordinator=coordinator) + self._project_id = project_id + self._attr_unique_id = f"{config_entry_id}-{project_id}" + self._attr_name = project_name + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data is None: + self._attr_todo_items = None + else: + items = [] + for task in self.coordinator.data: + if task.project_id != self._project_id: + continue + if task.is_completed: + status = TodoItemStatus.COMPLETED + else: + status = TodoItemStatus.NEEDS_ACTION + items.append( + TodoItem( + summary=task.content, + uid=task.id, + status=status, + ) + ) + self._attr_todo_items = items + super()._handle_coordinator_update() + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Create a To-do item.""" + if item.status != TodoItemStatus.NEEDS_ACTION: + raise ValueError("Only active tasks may be created.") + await self.coordinator.api.add_task( + content=item.summary or "", + project_id=self._project_id, + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + if item.summary: + await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if item.status is not None: + if item.status == TodoItemStatus.COMPLETED: + await self.coordinator.api.close_task(task_id=uid) + else: + await self.coordinator.api.reopen_task(task_id=uid) + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete a To-do item.""" + await asyncio.gather( + *[self.coordinator.api.delete_task(task_id=uid) for uid in uids] + ) + await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 626049276f5..25b814c106a 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -327,6 +327,7 @@ class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): """Base Tomorrow.io Entity.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -340,7 +341,6 @@ class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): self._config_entry = config_entry self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._config_entry.data[CONF_API_KEY])}, - name=INTEGRATION_NAME, manufacturer=INTEGRATION_NAME, sw_version=f"v{self.api_version}", entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 4aa2748ad30..947bbf6fd2f 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -25,7 +25,6 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, - CONF_NAME, PERCENTAGE, UnitOfIrradiance, UnitOfLength, @@ -75,10 +74,6 @@ from .const import ( class TomorrowioSensorEntityDescription(SensorEntityDescription): """Describes a Tomorrow.io sensor entity.""" - # TomorrowioSensor does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - attribute: str = "" unit_imperial: str | None = None unit_metric: str | None = None @@ -111,16 +106,16 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="feels_like", + translation_key="feels_like", attribute=TMRW_ATTR_FEELS_LIKE, - name="Feels Like", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="dew_point", + translation_key="dew_point", attribute=TMRW_ATTR_DEW_POINT, - name="Dew Point", icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -130,7 +125,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="pressure_surface_level", attribute=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, - name="Pressure (Surface Level)", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, @@ -140,7 +134,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="global_horizontal_irradiance", attribute=TMRW_ATTR_SOLAR_GHI, - name="Global Horizontal Irradiance", unit_imperial=UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, unit_metric=UnitOfIrradiance.WATTS_PER_SQUARE_METER, imperial_conversion=(1 / 3.15459), @@ -150,8 +143,8 @@ SENSOR_TYPES = ( # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key="cloud_base", + translation_key="cloud_base", attribute=TMRW_ATTR_CLOUD_BASE, - name="Cloud Base", icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, @@ -166,8 +159,8 @@ SENSOR_TYPES = ( # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key="cloud_ceiling", + translation_key="cloud_ceiling", attribute=TMRW_ATTR_CLOUD_CEILING, - name="Cloud Ceiling", icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, @@ -181,16 +174,16 @@ SENSOR_TYPES = ( ), TomorrowioSensorEntityDescription( key="cloud_cover", + translation_key="cloud_cover", attribute=TMRW_ATTR_CLOUD_COVER, - name="Cloud Cover", icon="mdi:cloud-percent", native_unit_of_measurement=PERCENTAGE, ), # Data comes in as m/s, convert to mi/h for imperial TomorrowioSensorEntityDescription( key="wind_gust", + translation_key="wind_gust", attribute=TMRW_ATTR_WIND_GUST, - name="Wind Gust", icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, unit_metric=UnitOfSpeed.METERS_PER_SECOND, @@ -202,10 +195,9 @@ SENSOR_TYPES = ( ), TomorrowioSensorEntityDescription( key="precipitation_type", - attribute=TMRW_ATTR_PRECIPITATION_TYPE, - name="Precipitation Type", - value_map=PrecipitationType, translation_key="precipitation_type", + attribute=TMRW_ATTR_PRECIPITATION_TYPE, + value_map=PrecipitationType, icon="mdi:weather-snowy-rainy", ), # Data comes in as ppb, convert to µg/m^3 @@ -213,7 +205,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="ozone", attribute=TMRW_ATTR_OZONE, - name="Ozone", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(48), device_class=SensorDeviceClass.OZONE, @@ -222,7 +213,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="particulate_matter_2_5_mm", attribute=TMRW_ATTR_PARTICULATE_MATTER_25, - name="Particulate Matter < 2.5 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, @@ -230,7 +220,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="particulate_matter_10_mm", attribute=TMRW_ATTR_PARTICULATE_MATTER_10, - name="Particulate Matter < 10 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, @@ -240,7 +229,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="nitrogen_dioxide", attribute=TMRW_ATTR_NITROGEN_DIOXIDE, - name="Nitrogen Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(46.01), device_class=SensorDeviceClass.NITROGEN_DIOXIDE, @@ -250,7 +238,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="carbon_monoxide", attribute=TMRW_ATTR_CARBON_MONOXIDE, - name="Carbon Monoxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, multiplication_factor=1 / 1000, device_class=SensorDeviceClass.CO, @@ -261,7 +248,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="sulphur_dioxide", attribute=TMRW_ATTR_SULPHUR_DIOXIDE, - name="Sulphur Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(64.07), device_class=SensorDeviceClass.SULPHUR_DIOXIDE, @@ -269,90 +255,82 @@ SENSOR_TYPES = ( ), TomorrowioSensorEntityDescription( key="us_epa_air_quality_index", + translation_key="us_epa_air_quality_index", attribute=TMRW_ATTR_EPA_AQI, - name="US EPA Air Quality Index", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="us_epa_primary_pollutant", - attribute=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, - name="US EPA Primary Pollutant", - value_map=PrimaryPollutantType, translation_key="primary_pollutant", + attribute=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + value_map=PrimaryPollutantType, ), TomorrowioSensorEntityDescription( key="us_epa_health_concern", - attribute=TMRW_ATTR_EPA_HEALTH_CONCERN, - name="US EPA Health Concern", - value_map=HealthConcernType, translation_key="health_concern", + attribute=TMRW_ATTR_EPA_HEALTH_CONCERN, + value_map=HealthConcernType, icon="mdi:hospital", ), TomorrowioSensorEntityDescription( key="china_mep_air_quality_index", + translation_key="china_mep_air_quality_index", attribute=TMRW_ATTR_CHINA_AQI, - name="China MEP Air Quality Index", device_class=SensorDeviceClass.AQI, ), TomorrowioSensorEntityDescription( key="china_mep_primary_pollutant", + translation_key="china_mep_primary_pollutant", attribute=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, - name="China MEP Primary Pollutant", value_map=PrimaryPollutantType, - translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( key="china_mep_health_concern", + translation_key="china_mep_health_concern", attribute=TMRW_ATTR_CHINA_HEALTH_CONCERN, - name="China MEP Health Concern", value_map=HealthConcernType, - translation_key="health_concern", icon="mdi:hospital", ), TomorrowioSensorEntityDescription( key="tree_pollen_index", + translation_key="pollen_index", attribute=TMRW_ATTR_POLLEN_TREE, - name="Tree Pollen Index", icon="mdi:tree", value_map=PollenIndex, - translation_key="pollen_index", ), TomorrowioSensorEntityDescription( key="weed_pollen_index", + translation_key="weed_pollen_index", attribute=TMRW_ATTR_POLLEN_WEED, - name="Weed Pollen Index", value_map=PollenIndex, - translation_key="pollen_index", icon="mdi:flower-pollen", ), TomorrowioSensorEntityDescription( key="grass_pollen_index", + translation_key="grass_pollen_index", attribute=TMRW_ATTR_POLLEN_GRASS, - name="Grass Pollen Index", icon="mdi:grass", value_map=PollenIndex, - translation_key="pollen_index", ), TomorrowioSensorEntityDescription( key="fire_index", + translation_key="fire_index", attribute=TMRW_ATTR_FIRE_INDEX, - name="Fire Index", icon="mdi:fire", ), TomorrowioSensorEntityDescription( key="uv_index", + translation_key="uv_index", attribute=TMRW_ATTR_UV_INDEX, - name="UV Index", state_class=SensorStateClass.MEASUREMENT, icon="mdi:sun-wireless", ), TomorrowioSensorEntityDescription( key="uv_radiation_health_concern", + translation_key="uv_radiation_health_concern", attribute=TMRW_ATTR_UV_HEALTH_CONCERN, - name="UV Radiation Health Concern", value_map=UVDescription, - translation_key="uv_index", icon="mdi:weather-sunny-alert", ), ) @@ -399,7 +377,6 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): """Initialize Tomorrow.io Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) self.entity_description = description - self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" self._attr_unique_id = f"{self._config_entry.unique_id}_{description.key}" if self.entity_description.native_unit_of_measurement is None: self._attr_native_unit_of_measurement = description.unit_metric diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index a104570f5c8..03a8a169920 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -33,36 +33,39 @@ }, "entity": { "sensor": { - "health_concern": { - "state": { - "good": "Good", - "moderate": "Moderate", - "unhealthy_for_sensitive_groups": "Unhealthy for Sensitive Groups", - "unhealthy": "Unhealthy", - "very_unhealthy": "Very Unhealthy", - "hazardous": "Hazardous" - } + "feels_like": { + "name": "Feels like" }, - "pollen_index": { - "state": { - "none": "None", - "very_low": "Very Low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very High" - } + "dew_point": { + "name": "Dew point" + }, + "cloud_base": { + "name": "Cloud base" + }, + "cloud_ceiling": { + "name": "Cloud ceiling" + }, + "cloud_cover": { + "name": "Cloud cover" + }, + "wind_gust": { + "name": "Wind gust" }, "precipitation_type": { + "name": "Precipitation type", "state": { "none": "None", "rain": "Rain", "snow": "Snow", - "freezing_rain": "Freezing Rain", - "ice_pellets": "Ice Pellets" + "freezing_rain": "Freezing rain", + "ice_pellets": "Ice pellets" } }, + "us_epa_air_quality_index": { + "name": "US EPA air quality index" + }, "primary_pollutant": { + "name": "US EPA primary pollutant", "state": { "pm25": "[%key:component::sensor::entity_component::pm25::name%]", "pm10": "[%key:component::sensor::entity_component::pm10::name%]", @@ -72,7 +75,83 @@ "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" } }, + "health_concern": { + "name": "US EPA health concern", + "state": { + "good": "Good", + "moderate": "Moderate", + "unhealthy_for_sensitive_groups": "Unhealthy for sensitive groups", + "unhealthy": "Unhealthy", + "very_unhealthy": "Very unhealthy", + "hazardous": "Hazardous" + } + }, + "china_mep_air_quality_index": { + "name": "China MEP air quality index" + }, + "china_mep_primary_pollutant": { + "name": "China MEP primary pollutant", + "state": { + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + } + }, + "china_mep_health_concern": { + "name": "China MEP health concern", + "state": { + "good": "[%key:component::tomorrowio::entity::sensor::health_concern::state::good%]", + "moderate": "[%key:component::tomorrowio::entity::sensor::health_concern::state::moderate%]", + "unhealthy_for_sensitive_groups": "[%key:component::tomorrowio::entity::sensor::health_concern::state::unhealthy_for_sensitive_groups%]", + "unhealthy": "[%key:component::tomorrowio::entity::sensor::health_concern::state::unhealthy%]", + "very_unhealthy": "[%key:component::tomorrowio::entity::sensor::health_concern::state::very_unhealthy%]", + "hazardous": "[%key:component::tomorrowio::entity::sensor::health_concern::state::hazardous%]" + } + }, + "pollen_index": { + "name": "Tree pollen index", + "state": { + "none": "None", + "very_low": "Very low", + "low": "Low", + "medium": "Medium", + "high": "High", + "very_high": "Very high" + } + }, + "weed_pollen_index": { + "name": "Weed pollen index", + "state": { + "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", + "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", + "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", + "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", + "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", + "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + } + }, + "grass_pollen_index": { + "name": "Grass pollen index", + "state": { + "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", + "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", + "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", + "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", + "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", + "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + } + }, + "fire_index": { + "name": "Fire index" + }, "uv_index": { + "name": "UV index" + }, + "uv_radiation_health_concern": { + "name": "UV radiation health concern", "state": { "low": "Low", "moderate": "Moderate", diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index b0b82d81463..06a147366e8 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -24,7 +24,6 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, - CONF_NAME, UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, @@ -118,7 +117,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, SingleCoordinatorWeatherEntity): self._attr_entity_registry_enabled_default = ( forecast_type == DEFAULT_FORECAST_TYPE ) - self._attr_name = f"{config_entry.data[CONF_NAME]} - {forecast_type.title()}" + self._attr_name = forecast_type.title() self._attr_unique_id = _calculate_unique_id( config_entry.unique_id, forecast_type ) diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index 67c36e92c78..5e5af394074 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/toon", "iot_class": "cloud_push", "loggers": ["toonapi"], - "requirements": ["toonapi==0.2.1"] + "requirements": ["toonapi==0.3.0"] } diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index b2fcc5c0161..e0ac41bdec6 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -155,11 +155,19 @@ { "hostname": "k[lps]*", "macaddress": "788CB5*" + }, + { + "hostname": "k[lps]*", + "macaddress": "3460F9*" + }, + { + "hostname": "k[lps]*", + "macaddress": "1C61B4*" } ], "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.5.3"] + "requirements": ["python-kasa[speedups]==0.5.4"] } diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index 5575f32788a..d9d28cfe13b 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -1,15 +1,23 @@ """The trafikverket_camera component.""" from __future__ import annotations +import logging + +from pytrafikverket.trafikverket_camera import TrafikverketCamera + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS +from .const import CONF_LOCATION, DOMAIN, PLATFORMS from .coordinator import TVDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trafikverket Camera from a config entry.""" @@ -30,3 +38,37 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + # Change entry unique id from location to camera id + if entry.version == 1: + location = entry.data[CONF_LOCATION] + api_key = entry.data[CONF_API_KEY] + + web_session = async_get_clientsession(hass) + camera_api = TrafikverketCamera(web_session, api_key) + + try: + camera_info = await camera_api.async_get_camera(location) + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Could not migrate the config entry. No connection to the api" + ) + return False + + if camera_id := camera_info.camera_id: + entry.version = 2 + _LOGGER.debug( + "Migrate Trafikverket Camera config entry unique id to %s", + camera_id, + ) + hass.config_entries.async_update_entry( + entry, + unique_id=f"{DOMAIN}-{camera_id}", + ) + return True + _LOGGER.error("Could not migrate the config entry. Camera has no id") + return False + return True diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index d4a282cb344..e75bc0bfa30 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -25,17 +25,18 @@ from .const import CONF_LOCATION, DOMAIN class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Camera integration.""" - VERSION = 1 + VERSION = 2 entry: config_entries.ConfigEntry | None async def validate_input( self, sensor_api: str, location: str - ) -> tuple[dict[str, str], str | None]: + ) -> tuple[dict[str, str], str | None, str | None]: """Validate input from user input.""" errors: dict[str, str] = {} camera_info: CameraInfo | None = None camera_location: str | None = None + camera_id: str | None = None web_session = async_get_clientsession(self.hass) camera_api = TrafikverketCamera(web_session, sensor_api) @@ -51,12 +52,13 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" if camera_info: + camera_id = camera_info.camera_id if _location := camera_info.location: camera_location = _location else: camera_location = camera_info.camera_name - return (errors, camera_location) + return (errors, camera_location, camera_id) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" @@ -74,7 +76,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors, _ = await self.validate_input( + errors, _, _ = await self.validate_input( api_key, self.entry.data[CONF_LOCATION] ) @@ -109,11 +111,13 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] location = user_input[CONF_LOCATION] - errors, camera_location = await self.validate_input(api_key, location) + errors, camera_location, camera_id = await self.validate_input( + api_key, location + ) if not errors: assert camera_location - await self.async_set_unique_id(f"{DOMAIN}-{camera_location}") + await self.async_set_unique_id(f"{DOMAIN}-{camera_id}") self._abort_if_unique_id_configured() return self.async_create_entry( title=camera_location, diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index d23631c6878..7b457063c6c 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.6"] + "requirements": ["pytrafikverket==0.3.7"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 9d0b904290c..7d0171bc8bb 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.6"] + "requirements": ["pytrafikverket==0.3.7"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index ab1f7feb3f7..b1dd39c5156 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -1,10 +1,10 @@ { "domain": "trafikverket_train", "name": "Trafikverket Train", - "codeowners": ["@endor-force", "@gjohansson-ST"], + "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.6"] + "requirements": ["pytrafikverket==0.3.7"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 138af544066..d9b4f20eeb7 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -1,10 +1,10 @@ { "domain": "trafikverket_weatherstation", "name": "Trafikverket Weather Station", - "codeowners": ["@endor-force", "@gjohansson-ST"], + "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.6"] + "requirements": ["pytrafikverket==0.3.7"] } diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 7e02c3d419d..df78c5d96aa 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -1,8 +1,6 @@ """Support for the Transmission BitTorrent client API.""" from __future__ import annotations -from collections.abc import Callable -from datetime import datetime, timedelta from functools import partial import logging import re @@ -14,17 +12,15 @@ from transmission_rpc.error import ( TransmissionConnectError, TransmissionError, ) -from transmission_rpc.session import SessionStats import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_USERNAME, Platform, ) @@ -35,37 +31,43 @@ from homeassistant.helpers import ( entity_registry as er, selector, ) -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from .const import ( ATTR_DELETE_DATA, ATTR_TORRENT, CONF_ENTRY_ID, - CONF_LIMIT, - CONF_ORDER, - DATA_UPDATED, DEFAULT_DELETE_DATA, - DEFAULT_LIMIT, - DEFAULT_ORDER, - DEFAULT_SCAN_INTERVAL, DOMAIN, - EVENT_DOWNLOADED_TORRENT, - EVENT_REMOVED_TORRENT, - EVENT_STARTED_TORRENT, SERVICE_ADD_TORRENT, SERVICE_REMOVE_TORRENT, SERVICE_START_TORRENT, SERVICE_STOP_TORRENT, ) +from .coordinator import TransmissionDataUpdateCoordinator from .errors import AuthenticationError, CannotConnect, UnknownError _LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] + +MIGRATION_NAME_TO_KEY = { + # Sensors + "Down Speed": "download", + "Up Speed": "upload", + "Status": "status", + "Active Torrents": "active_torrents", + "Paused Torrents": "paused_torrents", + "Total Torrents": "total_torrents", + "Completed Torrents": "completed_torrents", + "Started Torrents": "started_torrents", + # Switches + "Switch": "on_off", + "Turtle Mode": "turtle_mode", +} SERVICE_BASE_SCHEMA = vol.Schema( { - vol.Exclusive(CONF_ENTRY_ID, "identifier"): selector.ConfigEntrySelector(), + vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(), } ) @@ -95,25 +97,6 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All( ) ) -CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) - -PLATFORMS = [Platform.SENSOR, Platform.SWITCH] - -MIGRATION_NAME_TO_KEY = { - # Sensors - "Down Speed": "download", - "Up Speed": "upload", - "Status": "status", - "Active Torrents": "active_torrents", - "Paused Torrents": "paused_torrents", - "Total Torrents": "total_torrents", - "Completed Torrents": "completed_torrents", - "Started Torrents": "started_torrents", - # Switches - "Switch": "on_off", - "Turtle Mode": "turtle_mode", -} - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Transmission Component.""" @@ -123,6 +106,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b entity_entry: er.RegistryEntry, ) -> dict[str, Any] | None: """Update unique ID of entity entry.""" + if CONF_NAME not in config_entry.data: + return None match = re.search( f"{config_entry.data[CONF_HOST]}-{config_entry.data[CONF_NAME]} (?P.+)", entity_entry.unique_id, @@ -141,24 +126,80 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except (AuthenticationError, UnknownError) as error: raise ConfigEntryAuthFailed from error - client = TransmissionClient(hass, config_entry, api) - await client.async_setup() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = client + coordinator = TransmissionDataUpdateCoordinator(hass, config_entry, api) + await hass.async_add_executor_job(coordinator.init_torrent_list) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - client.register_services() + + async def add_torrent(service: ServiceCall) -> None: + """Add new torrent to download.""" + torrent = service.data[ATTR_TORRENT] + if torrent.startswith( + ("http", "ftp:", "magnet:") + ) or hass.config.is_allowed_path(torrent): + await hass.async_add_executor_job(coordinator.api.add_torrent, torrent) + await coordinator.async_request_refresh() + else: + _LOGGER.warning("Could not add torrent: unsupported type or no permission") + + async def start_torrent(service: ServiceCall) -> None: + """Start torrent.""" + torrent_id = service.data[CONF_ID] + await hass.async_add_executor_job(coordinator.api.start_torrent, torrent_id) + await coordinator.async_request_refresh() + + async def stop_torrent(service: ServiceCall) -> None: + """Stop torrent.""" + torrent_id = service.data[CONF_ID] + await hass.async_add_executor_job(coordinator.api.stop_torrent, torrent_id) + await coordinator.async_request_refresh() + + async def remove_torrent(service: ServiceCall) -> None: + """Remove torrent.""" + torrent_id = service.data[CONF_ID] + delete_data = service.data[ATTR_DELETE_DATA] + await hass.async_add_executor_job( + partial(coordinator.api.remove_torrent, torrent_id, delete_data=delete_data) + ) + await coordinator.async_request_refresh() + + hass.services.async_register( + DOMAIN, SERVICE_ADD_TORRENT, add_torrent, schema=SERVICE_ADD_TORRENT_SCHEMA + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_TORRENT, + remove_torrent, + schema=SERVICE_REMOVE_TORRENT_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_START_TORRENT, + start_torrent, + schema=SERVICE_START_TORRENT_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_STOP_TORRENT, + stop_torrent, + schema=SERVICE_STOP_TORRENT_SCHEMA, + ) + return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Transmission Entry from config_entry.""" - client: TransmissionClient = hass.data[DOMAIN].pop(config_entry.entry_id) - if client.unsub_timer: - client.unsub_timer() - - unload_ok = await hass.config_entries.async_unload_platforms( + if unload_ok := await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS - ) + ): + hass.data[DOMAIN].pop(config_entry.entry_id) if not hass.data[DOMAIN]: hass.services.async_remove(DOMAIN, SERVICE_ADD_TORRENT) @@ -200,288 +241,3 @@ async def get_api( except TransmissionError as error: _LOGGER.error(error) raise UnknownError from error - - -def _get_client(hass: HomeAssistant, data: dict[str, Any]) -> TransmissionClient | None: - """Return client from integration name or entry_id.""" - if ( - (entry_id := data.get(CONF_ENTRY_ID)) - and (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.state == ConfigEntryState.LOADED - ): - return hass.data[DOMAIN][entry_id] - - return None - - -class TransmissionClient: - """Transmission Client Object.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - api: transmission_rpc.Client, - ) -> None: - """Initialize the Transmission RPC API.""" - self.hass = hass - self.config_entry = config_entry - self.tm_api = api - self._tm_data = TransmissionData(hass, config_entry, api) - self.unsub_timer: Callable[[], None] | None = None - - @property - def api(self) -> TransmissionData: - """Return the TransmissionData object.""" - return self._tm_data - - async def async_setup(self) -> None: - """Set up the Transmission client.""" - await self.hass.async_add_executor_job(self.api.init_torrent_list) - await self.hass.async_add_executor_job(self.api.update) - self.add_options() - self.set_scan_interval(self.config_entry.options[CONF_SCAN_INTERVAL]) - - def register_services(self) -> None: - """Register integration services.""" - - def add_torrent(service: ServiceCall) -> None: - """Add new torrent to download.""" - if not (tm_client := _get_client(self.hass, service.data)): - raise ValueError("Transmission instance is not found") - - torrent = service.data[ATTR_TORRENT] - if torrent.startswith( - ("http", "ftp:", "magnet:") - ) or self.hass.config.is_allowed_path(torrent): - tm_client.tm_api.add_torrent(torrent) - tm_client.api.update() - else: - _LOGGER.warning( - "Could not add torrent: unsupported type or no permission" - ) - - def start_torrent(service: ServiceCall) -> None: - """Start torrent.""" - if not (tm_client := _get_client(self.hass, service.data)): - raise ValueError("Transmission instance is not found") - - torrent_id = service.data[CONF_ID] - tm_client.tm_api.start_torrent(torrent_id) - tm_client.api.update() - - def stop_torrent(service: ServiceCall) -> None: - """Stop torrent.""" - if not (tm_client := _get_client(self.hass, service.data)): - raise ValueError("Transmission instance is not found") - - torrent_id = service.data[CONF_ID] - tm_client.tm_api.stop_torrent(torrent_id) - tm_client.api.update() - - def remove_torrent(service: ServiceCall) -> None: - """Remove torrent.""" - if not (tm_client := _get_client(self.hass, service.data)): - raise ValueError("Transmission instance is not found") - - torrent_id = service.data[CONF_ID] - delete_data = service.data[ATTR_DELETE_DATA] - tm_client.tm_api.remove_torrent(torrent_id, delete_data=delete_data) - tm_client.api.update() - - self.hass.services.async_register( - DOMAIN, SERVICE_ADD_TORRENT, add_torrent, schema=SERVICE_ADD_TORRENT_SCHEMA - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_REMOVE_TORRENT, - remove_torrent, - schema=SERVICE_REMOVE_TORRENT_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_START_TORRENT, - start_torrent, - schema=SERVICE_START_TORRENT_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_STOP_TORRENT, - stop_torrent, - schema=SERVICE_STOP_TORRENT_SCHEMA, - ) - - self.config_entry.add_update_listener(self.async_options_updated) - - def add_options(self): - """Add options for entry.""" - if not self.config_entry.options: - scan_interval = self.config_entry.data.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - limit = self.config_entry.data.get(CONF_LIMIT, DEFAULT_LIMIT) - order = self.config_entry.data.get(CONF_ORDER, DEFAULT_ORDER) - options = { - CONF_SCAN_INTERVAL: scan_interval, - CONF_LIMIT: limit, - CONF_ORDER: order, - } - - self.hass.config_entries.async_update_entry( - self.config_entry, options=options - ) - - def set_scan_interval(self, scan_interval: float) -> None: - """Update scan interval.""" - - def refresh(event_time: datetime) -> None: - """Get the latest data from Transmission.""" - self.api.update() - - if self.unsub_timer is not None: - self.unsub_timer() - self.unsub_timer = async_track_time_interval( - self.hass, refresh, timedelta(seconds=scan_interval) - ) - - @staticmethod - async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Triggered by config entry options updates.""" - tm_client: TransmissionClient = hass.data[DOMAIN][entry.entry_id] - tm_client.set_scan_interval(entry.options[CONF_SCAN_INTERVAL]) - await hass.async_add_executor_job(tm_client.api.update) - - -class TransmissionData: - """Get the latest data and update the states.""" - - def __init__( - self, hass: HomeAssistant, config: ConfigEntry, api: transmission_rpc.Client - ) -> None: - """Initialize the Transmission RPC API.""" - self.hass = hass - self.config = config - self._api: transmission_rpc.Client = api - self.data: SessionStats | None = None - self.available: bool = True - self._session: transmission_rpc.Session | None = None - self._all_torrents: list[transmission_rpc.Torrent] = [] - self._completed_torrents: list[transmission_rpc.Torrent] = [] - self._started_torrents: list[transmission_rpc.Torrent] = [] - self._torrents: list[transmission_rpc.Torrent] = [] - - @property - def host(self) -> str: - """Return the host name.""" - return self.config.data[CONF_HOST] - - @property - def signal_update(self) -> str: - """Update signal per transmission entry.""" - return f"{DATA_UPDATED}-{self.host}" - - @property - def torrents(self) -> list[transmission_rpc.Torrent]: - """Get the list of torrents.""" - return self._torrents - - def update(self) -> None: - """Get the latest data from Transmission instance.""" - try: - self.data = self._api.session_stats() - self._torrents = self._api.get_torrents() - self._session = self._api.get_session() - - self.check_completed_torrent() - self.check_started_torrent() - self.check_removed_torrent() - _LOGGER.debug("Torrent Data for %s Updated", self.host) - - self.available = True - except TransmissionError: - self.available = False - _LOGGER.error("Unable to connect to Transmission client %s", self.host) - dispatcher_send(self.hass, self.signal_update) - - def init_torrent_list(self) -> None: - """Initialize torrent lists.""" - self._torrents = self._api.get_torrents() - self._completed_torrents = [ - torrent for torrent in self._torrents if torrent.status == "seeding" - ] - self._started_torrents = [ - torrent for torrent in self._torrents if torrent.status == "downloading" - ] - - def check_completed_torrent(self) -> None: - """Get completed torrent functionality.""" - old_completed_torrent_names = { - torrent.name for torrent in self._completed_torrents - } - - current_completed_torrents = [ - torrent for torrent in self._torrents if torrent.status == "seeding" - ] - - for torrent in current_completed_torrents: - if torrent.name not in old_completed_torrent_names: - self.hass.bus.fire( - EVENT_DOWNLOADED_TORRENT, {"name": torrent.name, "id": torrent.id} - ) - - self._completed_torrents = current_completed_torrents - - def check_started_torrent(self) -> None: - """Get started torrent functionality.""" - old_started_torrent_names = {torrent.name for torrent in self._started_torrents} - - current_started_torrents = [ - torrent for torrent in self._torrents if torrent.status == "downloading" - ] - - for torrent in current_started_torrents: - if torrent.name not in old_started_torrent_names: - self.hass.bus.fire( - EVENT_STARTED_TORRENT, {"name": torrent.name, "id": torrent.id} - ) - - self._started_torrents = current_started_torrents - - def check_removed_torrent(self) -> None: - """Get removed torrent functionality.""" - current_torrent_names = {torrent.name for torrent in self._torrents} - - for torrent in self._all_torrents: - if torrent.name not in current_torrent_names: - self.hass.bus.fire( - EVENT_REMOVED_TORRENT, {"name": torrent.name, "id": torrent.id} - ) - - self._all_torrents = self._torrents.copy() - - def start_torrents(self) -> None: - """Start all torrents.""" - if not self._torrents: - return - self._api.start_all() - - def stop_torrents(self) -> None: - """Stop all active torrents.""" - if not self._torrents: - return - torrent_ids = [torrent.id for torrent in self._torrents] - self._api.stop_torrent(torrent_ids) - - def set_alt_speed_enabled(self, is_enabled: bool) -> None: - """Set the alternative speed flag.""" - self._api.set_session(alt_speed_enabled=is_enabled) - - def get_alt_speed_enabled(self) -> bool | None: - """Get the alternative speed flag.""" - if self._session is None: - return None - - return self._session.alt_speed_enabled diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index d1005f5e84c..a987233fef0 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -7,14 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -26,7 +19,6 @@ from .const import ( DEFAULT_NAME, DEFAULT_ORDER, DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, DOMAIN, SUPPORTED_ORDER_MODES, ) @@ -34,7 +26,6 @@ from .errors import AuthenticationError, CannotConnect, UnknownError DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_HOST): str, vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str, @@ -64,15 +55,9 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - for entry in self._async_current_entries(): - if ( - entry.data[CONF_HOST] == user_input[CONF_HOST] - and entry.data[CONF_PORT] == user_input[CONF_PORT] - ): - return self.async_abort(reason="already_configured") - if entry.data[CONF_NAME] == user_input[CONF_NAME]: - errors[CONF_NAME] = "name_exists" - break + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) try: await get_api(self.hass, user_input) @@ -84,7 +69,8 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not errors: return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + title=DEFAULT_NAME, + data=user_input, ) return self.async_show_form( @@ -151,12 +137,6 @@ class TransmissionOptionsFlowHandler(config_entries.OptionsFlow): return self.async_create_entry(title="", data=user_input) options = { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): int, vol.Optional( CONF_LIMIT, default=self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT), diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index da861d2698c..77d2baf7213 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -1,4 +1,10 @@ """Constants for the Transmission Bittorent Client component.""" +from __future__ import annotations + +from collections.abc import Callable + +from transmission_rpc import Torrent + DOMAIN = "transmission" SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle mode"} @@ -8,7 +14,7 @@ ORDER_OLDEST_FIRST = "oldest_first" ORDER_BEST_RATIO_FIRST = "best_ratio_first" ORDER_WORST_RATIO_FIRST = "worst_ratio_first" -SUPPORTED_ORDER_MODES = { +SUPPORTED_ORDER_MODES: dict[str, Callable[[list[Torrent]], list[Torrent]]] = { ORDER_NEWEST_FIRST: lambda torrents: sorted( torrents, key=lambda t: t.date_added, reverse=True ), @@ -39,8 +45,6 @@ SERVICE_REMOVE_TORRENT = "remove_torrent" SERVICE_START_TORRENT = "start_torrent" SERVICE_STOP_TORRENT = "stop_torrent" -DATA_UPDATED = "transmission_data_updated" - EVENT_STARTED_TORRENT = "transmission_started_torrent" EVENT_REMOVED_TORRENT = "transmission_removed_torrent" EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent" diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py new file mode 100644 index 00000000000..91597d0e43d --- /dev/null +++ b/homeassistant/components/transmission/coordinator.py @@ -0,0 +1,161 @@ +"""Coordinator for transmssion integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import transmission_rpc +from transmission_rpc.session import SessionStats + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_LIMIT, + CONF_ORDER, + DEFAULT_LIMIT, + DEFAULT_ORDER, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + EVENT_DOWNLOADED_TORRENT, + EVENT_REMOVED_TORRENT, + EVENT_STARTED_TORRENT, +) + +_LOGGER = logging.getLogger(__name__) + + +class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): + """Transmission dataupdate coordinator class.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, api: transmission_rpc.Client + ) -> None: + """Initialize the Transmission RPC API.""" + self.config_entry = entry + self.api = api + self.host = entry.data[CONF_HOST] + self._session: transmission_rpc.Session | None = None + self._all_torrents: list[transmission_rpc.Torrent] = [] + self._completed_torrents: list[transmission_rpc.Torrent] = [] + self._started_torrents: list[transmission_rpc.Torrent] = [] + self.torrents: list[transmission_rpc.Torrent] = [] + super().__init__( + hass, + name=f"{DOMAIN} - {self.host}", + logger=_LOGGER, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + @property + def limit(self) -> int: + """Return limit.""" + return self.config_entry.data.get(CONF_LIMIT, DEFAULT_LIMIT) + + @property + def order(self) -> str: + """Return order.""" + return self.config_entry.data.get(CONF_ORDER, DEFAULT_ORDER) + + async def _async_update_data(self) -> SessionStats: + """Update transmission data.""" + return await self.hass.async_add_executor_job(self.update) + + def update(self) -> SessionStats: + """Get the latest data from Transmission instance.""" + try: + data = self.api.session_stats() + self.torrents = self.api.get_torrents() + self._session = self.api.get_session() + except transmission_rpc.TransmissionError as err: + raise UpdateFailed("Unable to connect to Transmission client") from err + + self.check_completed_torrent() + self.check_started_torrent() + self.check_removed_torrent() + + return data + + def init_torrent_list(self) -> None: + """Initialize torrent lists.""" + self.torrents = self.api.get_torrents() + self._completed_torrents = [ + torrent for torrent in self.torrents if torrent.status == "seeding" + ] + self._started_torrents = [ + torrent for torrent in self.torrents if torrent.status == "downloading" + ] + + def check_completed_torrent(self) -> None: + """Get completed torrent functionality.""" + old_completed_torrent_names = { + torrent.name for torrent in self._completed_torrents + } + + current_completed_torrents = [ + torrent for torrent in self.torrents if torrent.status == "seeding" + ] + + for torrent in current_completed_torrents: + if torrent.name not in old_completed_torrent_names: + self.hass.bus.fire( + EVENT_DOWNLOADED_TORRENT, {"name": torrent.name, "id": torrent.id} + ) + + self._completed_torrents = current_completed_torrents + + def check_started_torrent(self) -> None: + """Get started torrent functionality.""" + old_started_torrent_names = {torrent.name for torrent in self._started_torrents} + + current_started_torrents = [ + torrent for torrent in self.torrents if torrent.status == "downloading" + ] + + for torrent in current_started_torrents: + if torrent.name not in old_started_torrent_names: + self.hass.bus.fire( + EVENT_STARTED_TORRENT, {"name": torrent.name, "id": torrent.id} + ) + + self._started_torrents = current_started_torrents + + def check_removed_torrent(self) -> None: + """Get removed torrent functionality.""" + current_torrent_names = {torrent.name for torrent in self.torrents} + + for torrent in self._all_torrents: + if torrent.name not in current_torrent_names: + self.hass.bus.fire( + EVENT_REMOVED_TORRENT, {"name": torrent.name, "id": torrent.id} + ) + + self._all_torrents = self.torrents.copy() + + def start_torrents(self) -> None: + """Start all torrents.""" + if not self.torrents: + return + self.api.start_all() + + def stop_torrents(self) -> None: + """Stop all active torrents.""" + if not self.torrents: + return + torrent_ids = [torrent.id for torrent in self.torrents] + self.api.stop_torrent(torrent_ids) + + def set_alt_speed_enabled(self, is_enabled: bool) -> None: + """Set the alternative speed flag.""" + self.api.set_session(alt_speed_enabled=is_enabled) + + def get_alt_speed_enabled(self) -> bool | None: + """Get the alternative speed flag.""" + if self._session is None: + return None + + return self._session.alt_speed_enabled # type: ignore[no-any-return] diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 93bea8a25c9..c3ba418f885 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -8,17 +8,13 @@ from transmission_rpc.torrent import Torrent from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, STATE_IDLE, UnitOfDataRate -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import STATE_IDLE, UnitOfDataRate +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TransmissionClient from .const import ( - CONF_LIMIT, - CONF_ORDER, DOMAIN, STATE_ATTR_TORRENT_INFO, STATE_DOWNLOADING, @@ -26,6 +22,7 @@ from .const import ( STATE_UP_DOWN, SUPPORTED_ORDER_MODES, ) +from .coordinator import TransmissionDataUpdateCoordinator async def async_setup_entry( @@ -35,64 +32,59 @@ async def async_setup_entry( ) -> None: """Set up the Transmission sensors.""" - tm_client: TransmissionClient = hass.data[DOMAIN][config_entry.entry_id] - name: str = config_entry.data[CONF_NAME] + coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] - dev = [ + entities = [ TransmissionSpeedSensor( - tm_client, - name, + coordinator, "download_speed", "download", ), TransmissionSpeedSensor( - tm_client, - name, + coordinator, "upload_speed", "upload", ), TransmissionStatusSensor( - tm_client, - name, + coordinator, "transmission_status", "status", ), TransmissionTorrentsSensor( - tm_client, - name, + coordinator, "active_torrents", "active_torrents", ), TransmissionTorrentsSensor( - tm_client, - name, + coordinator, "paused_torrents", "paused_torrents", ), TransmissionTorrentsSensor( - tm_client, - name, + coordinator, "total_torrents", "total_torrents", ), TransmissionTorrentsSensor( - tm_client, - name, + coordinator, "completed_torrents", "completed_torrents", ), TransmissionTorrentsSensor( - tm_client, - name, + coordinator, "started_torrents", "started_torrents", ), ] - async_add_entities(dev, True) + async_add_entities(entities) -class TransmissionSensor(SensorEntity): +class TransmissionSensor( + CoordinatorEntity[TransmissionDataUpdateCoordinator], SensorEntity +): """A base class for all Transmission sensors.""" _attr_has_entity_name = True @@ -100,46 +92,19 @@ class TransmissionSensor(SensorEntity): def __init__( self, - tm_client: TransmissionClient, - client_name: str, + coordinator: TransmissionDataUpdateCoordinator, sensor_translation_key: str, key: str, ) -> None: """Initialize the sensor.""" - self._tm_client = tm_client + super().__init__(coordinator) self._attr_translation_key = sensor_translation_key self._key = key - self._state: StateType = None - self._attr_unique_id = f"{tm_client.config_entry.entry_id}-{key}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, tm_client.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Transmission", - name=client_name, - ) - - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - return self._state - - @property - def available(self) -> bool: - """Could the device be accessed during the last update call.""" - return self._tm_client.api.available - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, self._tm_client.api.signal_update, update - ) ) @@ -151,15 +116,15 @@ class TransmissionSpeedSensor(TransmissionSensor): _attr_suggested_display_precision = 2 _attr_suggested_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND - def update(self) -> None: - """Get the latest data from Transmission and updates the state.""" - if data := self._tm_client.api.data: - b_spd = ( - float(data.download_speed) - if self._key == "download" - else float(data.upload_speed) - ) - self._state = b_spd + @property + def native_value(self) -> float: + """Return the speed of the sensor.""" + data = self.coordinator.data + return ( + float(data.download_speed) + if self._key == "download" + else float(data.upload_speed) + ) class TransmissionStatusSensor(TransmissionSensor): @@ -168,21 +133,18 @@ class TransmissionStatusSensor(TransmissionSensor): _attr_device_class = SensorDeviceClass.ENUM _attr_options = [STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING] - def update(self) -> None: - """Get the latest data from Transmission and updates the state.""" - if data := self._tm_client.api.data: - 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: - self._state = STATE_SEEDING - elif upload == 0 and download > 0: - self._state = STATE_DOWNLOADING - else: - self._state = STATE_IDLE - else: - self._state = None + @property + def native_value(self) -> str: + """Return the value of the status sensor.""" + upload = self.coordinator.data.upload_speed + download = self.coordinator.data.download_speed + if upload > 0 and download > 0: + return STATE_UP_DOWN + if upload > 0 and download == 0: + return STATE_SEEDING + if upload == 0 and download > 0: + return STATE_DOWNLOADING + return STATE_IDLE class TransmissionTorrentsSensor(TransmissionSensor): @@ -208,21 +170,22 @@ class TransmissionTorrentsSensor(TransmissionSensor): def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes, if any.""" info = _torrents_info( - torrents=self._tm_client.api.torrents, - order=self._tm_client.config_entry.options[CONF_ORDER], - limit=self._tm_client.config_entry.options[CONF_LIMIT], + torrents=self.coordinator.torrents, + order=self.coordinator.order, + limit=self.coordinator.limit, statuses=self.MODES[self._key], ) return { STATE_ATTR_TORRENT_INFO: info, } - def update(self) -> None: - """Get the latest data from Transmission and updates the state.""" + @property + def native_value(self) -> int: + """Return the count of the sensor.""" torrents = _filter_torrents( - self._tm_client.api.torrents, statuses=self.MODES[self._key] + self.coordinator.torrents, statuses=self.MODES[self._key] ) - self._state = len(torrents) + return len(torrents) def _filter_torrents( diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index aaab4d2e2d7..77ffd6a8b2a 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -4,7 +4,6 @@ "user": { "title": "Set up Transmission Client", "data": { - "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", @@ -20,7 +19,6 @@ } }, "error": { - "name_exists": "Name already exists", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, @@ -32,9 +30,7 @@ "options": { "step": { "init": { - "title": "Configure options for Transmission", "data": { - "scan_interval": "Update frequency", "limit": "Limit", "order": "Order" } diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index fad099fc5b9..6d236964987 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -5,14 +5,13 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TransmissionClient from .const import DOMAIN, SWITCH_TYPES +from .coordinator import TransmissionDataUpdateCoordinator _LOGGING = logging.getLogger(__name__) @@ -24,17 +23,20 @@ async def async_setup_entry( ) -> None: """Set up the Transmission switch.""" - tm_client: TransmissionClient = hass.data[DOMAIN][config_entry.entry_id] - name: str = config_entry.data[CONF_NAME] + coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] - dev = [] + entities = [] for switch_type, switch_name in SWITCH_TYPES.items(): - dev.append(TransmissionSwitch(switch_type, switch_name, tm_client, name)) + entities.append(TransmissionSwitch(switch_type, switch_name, coordinator)) - async_add_entities(dev, True) + async_add_entities(entities) -class TransmissionSwitch(SwitchEntity): +class TransmissionSwitch( + CoordinatorEntity[TransmissionDataUpdateCoordinator], SwitchEntity +): """Representation of a Transmission switch.""" _attr_has_entity_name = True @@ -44,84 +46,51 @@ class TransmissionSwitch(SwitchEntity): self, switch_type: str, switch_name: str, - tm_client: TransmissionClient, - client_name: str, + coordinator: TransmissionDataUpdateCoordinator, ) -> None: """Initialize the Transmission switch.""" + super().__init__(coordinator) self._attr_name = switch_name self.type = switch_type - self._tm_client = tm_client - self._state = STATE_OFF - self._data = None self.unsub_update: Callable[[], None] | None = None - self._attr_unique_id = f"{tm_client.config_entry.entry_id}-{switch_type}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{switch_type}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, tm_client.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Transmission", - name=client_name, ) @property def is_on(self) -> bool: """Return true if device is on.""" - return self._state == STATE_ON + active = None + if self.type == "on_off": + active = self.coordinator.data.active_torrent_count > 0 + elif self.type == "turtle_mode": + active = self.coordinator.get_alt_speed_enabled() - @property - def available(self) -> bool: - """Could the device be accessed during the last update call.""" - return self._tm_client.api.available + return bool(active) - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if self.type == "on_off": _LOGGING.debug("Starting all torrents") - self._tm_client.api.start_torrents() + await self.hass.async_add_executor_job(self.coordinator.start_torrents) elif self.type == "turtle_mode": _LOGGING.debug("Turning Turtle Mode of Transmission on") - self._tm_client.api.set_alt_speed_enabled(True) - self._tm_client.api.update() + await self.hass.async_add_executor_job( + self.coordinator.set_alt_speed_enabled, True + ) + await self.coordinator.async_request_refresh() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if self.type == "on_off": _LOGGING.debug("Stopping all torrents") - self._tm_client.api.stop_torrents() + await self.hass.async_add_executor_job(self.coordinator.stop_torrents) if self.type == "turtle_mode": _LOGGING.debug("Turning Turtle Mode of Transmission off") - self._tm_client.api.set_alt_speed_enabled(False) - self._tm_client.api.update() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - self.unsub_update = async_dispatcher_connect( - self.hass, - self._tm_client.api.signal_update, - self._schedule_immediate_update, - ) - - @callback - def _schedule_immediate_update(self) -> None: - self.async_schedule_update_ha_state(True) - - async def will_remove_from_hass(self) -> None: - """Unsubscribe from update dispatcher.""" - if self.unsub_update: - self.unsub_update() - self.unsub_update = None - - def update(self) -> None: - """Get the latest data from Transmission and updates the state.""" - active = None - if self.type == "on_off": - self._data = self._tm_client.api.data - if self._data: - active = self._data.active_torrent_count > 0 - - elif self.type == "turtle_mode": - active = self._tm_client.api.get_alt_speed_enabled() - - if active is None: - return - - self._state = STATE_ON if active else STATE_OFF + await self.hass.async_add_executor_job( + self.coordinator.set_alt_speed_enabled, False + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 1ea58f5029f..9c807419551 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -112,7 +112,7 @@ }, "number": { "temperature": { - "name": "[%key:component::number::entity_component::temperature::name%]" + "name": "[%key:component::sensor::entity_component::temperature::name%]" }, "time": { "name": "Time" diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index cfacc9072f2..6cb98444be6 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "platinum", - "requirements": ["twentemilieu==1.0.0"] + "requirements": ["twentemilieu==2.0.0"] } diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json index be4af8d5ae6..44e8712b029 100644 --- a/homeassistant/components/twitter/manifest.json +++ b/homeassistant/components/twitter/manifest.json @@ -1,6 +1,6 @@ { "domain": "twitter", - "name": "Twitter", + "name": "X", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/twitter", "iot_class": "cloud_push", diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 8c0696463c5..a678517eca9 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -34,6 +34,7 @@ from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, + CONF_CLIENT_SOURCE, CONF_DETECTION_TIME, CONF_DPI_RESTRICTIONS, CONF_IGNORE_WIRED_BUG, @@ -257,7 +258,7 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): self.options[CONF_BLOCK_CLIENT] = self.controller.option_block_clients if self.show_advanced_options: - return await self.async_step_device_tracker() + return await self.async_step_configure_entity_sources() return await self.async_step_simple_options() @@ -296,6 +297,32 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): last_step=True, ) + async def async_step_configure_entity_sources( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select sources for entities.""" + if user_input is not None: + self.options.update(user_input) + return await self.async_step_device_tracker() + + clients = { + client.mac: f"{client.name or client.hostname} ({client.mac})" + for client in self.controller.api.clients.values() + } + + return self.async_show_form( + step_id="configure_entity_sources", + data_schema=vol.Schema( + { + vol.Optional( + CONF_CLIENT_SOURCE, + default=self.options.get(CONF_CLIENT_SOURCE, []), + ): cv.multi_select(clients), + } + ), + last_step=False, + ) + async def async_step_device_tracker( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index 176511645aa..c78313f66e2 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -23,6 +23,7 @@ UNIFI_WIRELESS_CLIENTS = "unifi_wireless_clients" CONF_ALLOW_BANDWIDTH_SENSORS = "allow_bandwidth_sensors" CONF_ALLOW_UPTIME_SENSORS = "allow_uptime_sensors" CONF_BLOCK_CLIENT = "block_client" +CONF_CLIENT_SOURCE = "client_source" CONF_DETECTION_TIME = "detection_time" CONF_DPI_RESTRICTIONS = "dpi_restrictions" CONF_IGNORE_WIRED_BUG = "ignore_wired_bug" diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 278af31b6b9..b89e64f285f 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -47,6 +47,7 @@ from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, + CONF_CLIENT_SOURCE, CONF_DETECTION_TIME, CONF_DPI_RESTRICTIONS, CONF_IGNORE_WIRED_BUG, @@ -109,6 +110,9 @@ class UniFiController: """Store attributes to avoid property call overhead since they are called frequently.""" options = self.config_entry.options + # Allow creating entities from clients. + self.option_supported_clients: list[str] = options.get(CONF_CLIENT_SOURCE, []) + # Device tracker options # Config entry option to not track clients. @@ -259,7 +263,7 @@ class UniFiController: if entry.domain == Platform.DEVICE_TRACKER: macs.append(entry.unique_id.split("-", 1)[0]) - for mac in self.option_block_clients + macs: + for mac in self.option_supported_clients + self.option_block_clients + macs: if mac not in self.api.clients and mac in self.api.clients_all: self.api.clients.process_raw([dict(self.api.clients_all[mac].raw)]) @@ -317,7 +321,7 @@ class UniFiController: self.poe_command_queue.clear() for device_id, device_commands in queue.items(): device = self.api.devices[device_id] - commands = [(idx, mode) for idx, mode in device_commands.items()] + commands = list(device_commands.items()) await self.api.request( DeviceSetPoePortModeRequest.create(device, targets=commands) ) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 22a530e0369..5c9694c669c 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -80,6 +80,9 @@ WIRELESS_DISCONNECTION = ( @callback def async_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: """Check if client is allowed.""" + if obj_id in controller.option_supported_clients: + return True + if not controller.option_track_clients: return False diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7673402aaac..f1fc4777467 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==63"], + "requirements": ["aiounifi==64"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 86c6b0d6352..3d0ffa1896e 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -27,10 +27,11 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, UnitOfTemperature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfPower +from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfPower from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -48,6 +49,22 @@ from .entity import ( ) +@callback +def async_bandwidth_sensor_allowed_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client is allowed.""" + if obj_id in controller.option_supported_clients: + return True + return controller.option_allow_bandwidth_sensors + + +@callback +def async_uptime_sensor_allowed_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client is allowed.""" + if obj_id in controller.option_supported_clients: + return True + return controller.option_allow_uptime_sensors + + @callback def async_client_rx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate receiving data transfer value.""" @@ -133,10 +150,12 @@ class UnifiSensorEntityDescription( ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor RX", - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + icon="mdi:upload", has_entity_name=True, - allowed_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, + allowed_fn=async_bandwidth_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, available_fn=lambda controller, _: controller.available, device_info_fn=async_client_device_info_fn, @@ -151,10 +170,12 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor TX", - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + icon="mdi:download", has_entity_name=True, - allowed_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, + allowed_fn=async_bandwidth_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, available_fn=lambda controller, _: controller.available, device_info_fn=async_client_device_info_fn, @@ -193,7 +214,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, entity_registry_enabled_default=False, - allowed_fn=lambda controller, _: controller.option_allow_uptime_sensors, + allowed_fn=async_uptime_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, available_fn=lambda controller, obj_id: controller.available, device_info_fn=async_client_device_info_fn, diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index e441d4695ed..9c609ca8c07 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -30,6 +30,13 @@ "integration_not_setup": "UniFi integration is not set up" }, "step": { + "configure_entity_sources": { + "data": { + "client_source": "Create entities from network clients" + }, + "description": "Select sources to create entities from", + "title": "UniFi Network Entity Sources" + }, "device_tracker": { "data": { "detection_time": "Time in seconds from last seen until considered away", diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 0aa39914686..41c1f55a22a 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -60,6 +60,14 @@ CLIENT_BLOCKED = (EventKey.WIRED_CLIENT_BLOCKED, EventKey.WIRELESS_CLIENT_BLOCKE CLIENT_UNBLOCKED = (EventKey.WIRED_CLIENT_UNBLOCKED, EventKey.WIRELESS_CLIENT_UNBLOCKED) +@callback +def async_block_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client is allowed.""" + if obj_id in controller.option_supported_clients: + return True + return obj_id in controller.option_block_clients + + @callback def async_dpi_group_is_on_fn( controller: UniFiController, dpi_group: DPIRestrictionGroup @@ -198,7 +206,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, has_entity_name=True, icon="mdi:ethernet", - allowed_fn=lambda controller, obj_id: obj_id in controller.option_block_clients, + allowed_fn=async_block_client_allowed_fn, api_handler_fn=lambda api: api.clients, available_fn=lambda controller, obj_id: controller.available, control_fn=async_block_client_control_fn, diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 174d35f07e0..a2554858fef 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -211,7 +211,7 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): def is_on(self) -> bool: """Return true if the server is on.""" try: - return STATE_MAP.get(self._server.state, self._server.state) == STATE_ON + return STATE_MAP.get(self._server.state, self._server.state) == STATE_ON # type: ignore[no-any-return] except AttributeError: return False diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index cd581d8c37f..794a65db03a 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -534,16 +534,25 @@ class UtilityMeterSensor(RestoreSensor): self.async_write_ha_state() - async def _async_reset_meter(self, event): - """Determine cycle - Helper function for larger than daily cycles.""" + async def _program_reset(self): + """Program the reset of the utility meter.""" if self._cron_pattern is not None: + tz = dt_util.get_time_zone(self.hass.config.time_zone) self.async_on_remove( async_track_point_in_time( self.hass, self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now()).get_next(datetime), + croniter(self._cron_pattern, dt_util.now(tz)).get_next( + datetime + ), # we need timezone for DST purposes (see issue #102984) ) ) + + async def _async_reset_meter(self, event): + """Reset the utility meter status.""" + + await self._program_reset() + await self.async_reset_meter(self._tariff_entity) async def async_reset_meter(self, entity_id): @@ -566,14 +575,7 @@ class UtilityMeterSensor(RestoreSensor): """Handle entity which will be added.""" await super().async_added_to_hass() - if self._cron_pattern is not None: - self.async_on_remove( - async_track_point_in_time( - self.hass, - self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now()).get_next(datetime), - ) - ) + await self._program_reset() self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 68d50d1c2fc..c0680913df6 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -40,7 +40,11 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass +from homeassistant.loader import ( + async_get_issue_tracker, + async_suggest_report_issue, + bind_hass, +) _LOGGER = logging.getLogger(__name__) @@ -384,6 +388,16 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): # we don't worry about demo and mqtt has it's own deprecation warnings. if self.platform.platform_name in ("demo", "mqtt"): return + translation_key = "deprecated_vacuum_base_class" + translation_placeholders = {"platform": self.platform.platform_name} + issue_tracker = async_get_issue_tracker( + hass, + integration_domain=self.platform.platform_name, + module=type(self).__module__, + ) + if issue_tracker: + translation_placeholders["issue_tracker"] = issue_tracker + translation_key = "deprecated_vacuum_base_class_url" ir.async_create_issue( hass, DOMAIN, @@ -393,21 +407,24 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): is_persistent=False, issue_domain=self.platform.platform_name, severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_vacuum_base_class", - translation_placeholders={ - "platform": self.platform.platform_name, - }, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + report_issue = async_suggest_report_issue( + hass, + integration_domain=self.platform.platform_name, + module=type(self).__module__, ) _LOGGER.warning( ( "%s::%s is extending the deprecated base class VacuumEntity instead of " "StateVacuumEntity, this is not valid and will be unsupported " - "from Home Assistant 2024.2. Please report it to the author of the '%s'" - " custom integration" + "from Home Assistant 2024.2. Please %s" ), self.platform.platform_name, self.__class__.__name__, - self.platform.platform_name, + report_issue, ) entity_description: VacuumEntityDescription diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 73e50af5caa..3c018fc1a89 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -33,6 +33,10 @@ "deprecated_vacuum_base_class": { "title": "The {platform} custom integration is using deprecated vacuum feature", "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease report it to the author of the `{platform}` custom integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." + }, + "deprecated_vacuum_base_class_url": { + "title": "[%key:component::vacuum::issues::deprecated_vacuum_base_class::title%]", + "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease create a bug report at {issue_tracker}.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." } }, "services": { diff --git a/homeassistant/components/vasttrafik/manifest.json b/homeassistant/components/vasttrafik/manifest.json index aa1907a8a23..336d06e182c 100644 --- a/homeassistant/components/vasttrafik/manifest.json +++ b/homeassistant/components/vasttrafik/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/vasttrafik", "iot_class": "cloud_polling", "loggers": ["vasttrafik"], - "requirements": ["vtjp==0.1.14"] + "requirements": ["vtjp==0.2.1"] } diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 711f66ea033..6a083232079 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -1,7 +1,7 @@ """Support for Västtrafik public transport.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging import vasttrafik @@ -22,6 +22,9 @@ ATTR_ACCESSIBILITY = "accessibility" ATTR_DIRECTION = "direction" ATTR_LINE = "line" ATTR_TRACK = "track" +ATTR_FROM = "from" +ATTR_TO = "to" +ATTR_DELAY = "delay" CONF_DEPARTURES = "departures" CONF_FROM = "from" @@ -32,7 +35,6 @@ CONF_SECRET = "secret" DEFAULT_DELAY = 0 - MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -101,7 +103,7 @@ class VasttrafikDepartureSensor(SensorEntity): if location.isdecimal(): station_info = {"station_name": location, "station_id": location} else: - station_id = self._planner.location_name(location)[0]["id"] + station_id = self._planner.location_name(location)[0]["gid"] station_info = {"station_name": location, "station_id": station_id} return station_info @@ -143,20 +145,36 @@ class VasttrafikDepartureSensor(SensorEntity): self._attributes = {} else: for departure in self._departureboard: - line = departure.get("sname") - if "cancelled" in departure: + service_journey = departure.get("serviceJourney", {}) + line = service_journey.get("line", {}) + + if departure.get("isCancelled"): continue - if not self._lines or line in self._lines: - if "rtTime" in departure: - self._state = departure["rtTime"] + if not self._lines or line.get("shortName") in self._lines: + if "estimatedOtherwisePlannedTime" in departure: + try: + self._state = datetime.fromisoformat( + departure["estimatedOtherwisePlannedTime"] + ).strftime("%H:%M") + except ValueError: + self._state = departure["estimatedOtherwisePlannedTime"] else: - self._state = departure["time"] + self._state = None + + stop_point = departure.get("stopPoint", {}) params = { - ATTR_ACCESSIBILITY: departure.get("accessibility"), - ATTR_DIRECTION: departure.get("direction"), - ATTR_LINE: departure.get("sname"), - ATTR_TRACK: departure.get("track"), + ATTR_ACCESSIBILITY: "wheelChair" + if line.get("isWheelchairAccessible") + else None, + ATTR_DIRECTION: service_journey.get("direction"), + ATTR_LINE: line.get("shortName"), + ATTR_TRACK: stop_point.get("platform"), + ATTR_FROM: stop_point.get("name"), + ATTR_TO: self._heading["station_name"] + if self._heading + else "ANY", + ATTR_DELAY: self._delay.seconds // 60 % 60, } self._attributes = {k: v for k, v in params.items() if v} diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 5c35303f859..1888a177895 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -import velbusaio +import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 229ee8458c6..3c773e39e33 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.10.1"], + "requirements": ["velbus-aio==2023.10.2"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index f87f1cf3a8a..a0e5b9da52e 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -36,9 +36,12 @@ SKU_TO_BASE_DEVICE = { "LAP-C601S-WUS": "Core600S", # Alt ID Model Core600S "LAP-C601S-WUSR": "Core600S", # Alt ID Model Core600S "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S, - "LAP-V201S-AASR": "Vital200S", - "LAP-V201S-WJP": "Vital200S", - "LAP-V201S-WEU": "Vital200S", - "LAP-V201S-WUS": "Vital200S", - "LAP-V201-AUSR": "Vital200S", + "Vital200S": "Vital200S", + "LAP-V201S-AASR": "Vital200S", # Alt ID Model Vital200S + "LAP-V201S-WJP": "Vital200S", # Alt ID Model Vital200S + "LAP-V201S-WEU": "Vital200S", # Alt ID Model Vital200S + "LAP-V201S-WUS": "Vital200S", # Alt ID Model Vital200S + "LAP-V201-AUSR": "Vital200S", # Alt ID Model Vital200S + "Vital100S": "Vital100S", + "LAP-V102S-WUS": "Vital100S", # Alt ID Model Vital100S, } diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 87934ced81f..326e7daf12c 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -28,6 +28,7 @@ DEV_TYPE_TO_HA = { "Core400S": "fan", "Core600S": "fan", "Vital200S": "fan", + "Vital100S": "fan", } FAN_MODE_AUTO = "auto" @@ -41,6 +42,7 @@ PRESET_MODES = { "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], + "Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), @@ -49,6 +51,7 @@ SPEED_RANGE = { # off is not included "Core400S": (1, 4), "Core600S": (1, 4), "Vital200S": (1, 4), + "Vital100S": (1, 4), } diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index dcf8e7d2860..fb892acfd4f 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.1"] + "requirements": ["pyvesync==2.1.10"] } diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 269695a668d..7a297ca8113 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,11 +1,12 @@ """The ViCare integration.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import suppress from dataclasses import dataclass import logging import os +from typing import Any from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device @@ -23,6 +24,7 @@ from .const import ( PLATFORMS, VICARE_API, VICARE_DEVICE_CONFIG, + VICARE_DEVICE_CONFIG_LIST, HeatingType, ) @@ -38,10 +40,9 @@ class ViCareRequiredKeysMixin: @dataclass() -class ViCareRequiredKeysMixinWithSet: +class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin): """Mixin for required keys with setter.""" - value_getter: Callable[[Device], bool] value_setter: Callable[[Device], bool] @@ -59,7 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def vicare_login(hass, entry_data): +def vicare_login(hass: HomeAssistant, entry_data: Mapping[str, Any]) -> PyViCare: """Login via PyVicare API.""" vicare_api = PyViCare() vicare_api.setCacheDuration(DEFAULT_SCAN_INTERVAL) @@ -72,7 +73,7 @@ def vicare_login(hass, entry_data): return vicare_api -def setup_vicare_api(hass, entry): +def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None: """Set up PyVicare API.""" vicare_api = vicare_login(hass, entry.data) @@ -82,7 +83,9 @@ def setup_vicare_api(hass, entry): ) # Currently we only support a single device - device = vicare_api.devices[0] + device_list = vicare_api.devices + device = device_list[0] + hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST] = device_list hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] = device hass.data[DOMAIN][entry.entry_id][VICARE_API] = getattr( device, diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 5aa76dc9962..4e3d8d05f97 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -5,6 +5,7 @@ from contextlib import suppress from dataclasses import dataclass import logging +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -19,11 +20,12 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .entity import ViCareEntity +from .utils import is_supported _LOGGER = logging.getLogger(__name__) @@ -38,14 +40,15 @@ class ViCareBinarySensorEntityDescription( CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="circulationpump_active", - name="Circulation pump active", - device_class=BinarySensorDeviceClass.POWER, + name="Circulation pump", + icon="mdi:pump", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getCirculationPumpActive(), ), ViCareBinarySensorEntityDescription( key="frost_protection_active", - name="Frost protection active", - device_class=BinarySensorDeviceClass.POWER, + name="Frost protection", + icon="mdi:snowflake", value_getter=lambda api: api.getFrostProtectionActive(), ), ) @@ -53,8 +56,9 @@ CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( BURNER_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="burner_active", - name="Burner active", - device_class=BinarySensorDeviceClass.POWER, + name="Burner", + icon="mdi:gas-burner", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getActive(), ), ) @@ -62,8 +66,8 @@ BURNER_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( COMPRESSOR_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="compressor_active", - name="Compressor active", - device_class=BinarySensorDeviceClass.POWER, + name="Compressor", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getActive(), ), ) @@ -71,54 +75,58 @@ COMPRESSOR_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="solar_pump_active", - name="Solar pump active", - device_class=BinarySensorDeviceClass.POWER, + name="Solar pump", + icon="mdi:pump", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getSolarPumpActive(), ), ViCareBinarySensorEntityDescription( key="charging_active", - name="DHW Charging active", + name="DHW Charging", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterChargingActive(), ), ViCareBinarySensorEntityDescription( key="dhw_circulationpump_active", - name="DHW Circulation Pump Active", - device_class=BinarySensorDeviceClass.POWER, + name="DHW Circulation Pump", + icon="mdi:pump", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterCirculationPumpActive(), ), ViCareBinarySensorEntityDescription( key="dhw_pump_active", - name="DHW Pump Active", - device_class=BinarySensorDeviceClass.POWER, + name="DHW Pump", + icon="mdi:pump", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterPumpActive(), ), ) -def _build_entity(name, vicare_api, device_config, sensor): +def _build_entity( + name: str, + vicare_api, + device_config: PyViCareDeviceConfig, + entity_description: ViCareBinarySensorEntityDescription, +): """Create a ViCare binary sensor entity.""" - try: - sensor.value_getter(vicare_api) - _LOGGER.debug("Found entity %s", name) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return None - except AttributeError: - _LOGGER.debug("Attribute Error %s", name) - return None - - return ViCareBinarySensor( - name, - vicare_api, - device_config, - sensor, - ) + if is_supported(name, entity_description, vicare_api): + return ViCareBinarySensor( + name, + vicare_api, + device_config, + entity_description, + ) + return None async def _entities_from_descriptions( - hass, name, entities, sensor_descriptions, iterables, config_entry -): + hass: HomeAssistant, + entities: list[ViCareBinarySensor], + sensor_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], + iterables, + config_entry: ConfigEntry, +) -> None: """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: for current in iterables: @@ -127,7 +135,7 @@ async def _entities_from_descriptions( suffix = f" {current.id}" entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}{suffix}", + f"{description.name}{suffix}", current, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -142,7 +150,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare binary sensor devices.""" - name = VICARE_NAME api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] entities = [] @@ -150,7 +157,7 @@ async def async_setup_entry( for description in GLOBAL_SENSORS: entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}", + description.name, api, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -160,21 +167,21 @@ async def async_setup_entry( try: await _entities_from_descriptions( - hass, name, entities, CIRCUIT_SENSORS, api.circuits, config_entry + hass, entities, CIRCUIT_SENSORS, api.circuits, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No circuits found") try: await _entities_from_descriptions( - hass, name, entities, BURNER_SENSORS, api.burners, config_entry + hass, entities, BURNER_SENSORS, api.burners, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No burners found") try: await _entities_from_descriptions( - hass, name, entities, COMPRESSOR_SENSORS, api.compressors, config_entry + hass, entities, COMPRESSOR_SENSORS, api.compressors, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No compressors found") @@ -182,7 +189,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareBinarySensor(BinarySensorEntity): +class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): """Representation of a ViCare sensor.""" entity_description: ViCareBinarySensorEntityDescription @@ -191,18 +198,11 @@ class ViCareBinarySensor(BinarySensorEntity): self, name, api, device_config, description: ViCareBinarySensorEntityDescription ) -> None: """Initialize the sensor.""" + super().__init__(device_config) self.entity_description = description self._attr_name = name self._api = api - self.entity_description = description self._device_config = device_config - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_config.getConfig().serial)}, - name=device_config.getModel(), - manufacturer="Viessmann", - model=device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) @property def available(self): diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 7fd8cccd3a4..2516446a94e 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -5,6 +5,7 @@ from contextlib import suppress from dataclasses import dataclass import logging +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -16,11 +17,12 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixinWithSet -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .entity import ViCareEntity +from .utils import is_supported _LOGGER = logging.getLogger(__name__) @@ -46,25 +48,22 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( ) -def _build_entity(name, vicare_api, device_config, description): +def _build_entity( + name: str, + vicare_api, + device_config: PyViCareDeviceConfig, + entity_description: ViCareButtonEntityDescription, +): """Create a ViCare button entity.""" _LOGGER.debug("Found device %s", name) - try: - description.value_getter(vicare_api) - _LOGGER.debug("Found entity %s", name) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return None - except AttributeError: - _LOGGER.debug("Attribute Error %s", name) - return None - - return ViCareButton( - name, - vicare_api, - device_config, - description, - ) + if is_supported(name, entity_description, vicare_api): + return ViCareButton( + name, + vicare_api, + device_config, + entity_description, + ) + return None async def async_setup_entry( @@ -73,7 +72,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare button entities.""" - name = VICARE_NAME api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] entities = [] @@ -81,7 +79,7 @@ async def async_setup_entry( for description in BUTTON_DESCRIPTIONS: entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}", + description.name, api, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -92,7 +90,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareButton(ButtonEntity): +class ViCareButton(ViCareEntity, ButtonEntity): """Representation of a ViCare button.""" entity_description: ViCareButtonEntityDescription @@ -101,16 +99,10 @@ class ViCareButton(ButtonEntity): self, name, api, device_config, description: ViCareButtonEntityDescription ) -> None: """Initialize the button.""" + super().__init__(device_config) self.entity_description = description self._device_config = device_config self._api = api - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_config.getConfig().serial)}, - name=device_config.getModel(), - manufacturer="Viessmann", - model=device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) def press(self) -> None: """Handle the button press.""" diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index a9188adc964..d306cc6604d 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -33,10 +33,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,7 @@ VICARE_HOLD_MODE_OFF = "off" VICARE_TEMP_HEATING_MIN = 3 VICARE_TEMP_HEATING_MAX = 37 -VICARE_TO_HA_HVAC_HEATING = { +VICARE_TO_HA_HVAC_HEATING: dict[str, HVACMode] = { VICARE_MODE_FORCEDREDUCED: HVACMode.OFF, VICARE_MODE_OFF: HVACMode.OFF, VICARE_MODE_DHW: HVACMode.OFF, @@ -105,7 +105,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - name = VICARE_NAME entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] circuits = await hass.async_add_executor_job(_get_circuits, api) @@ -116,7 +115,7 @@ async def async_setup_entry( suffix = f" {circuit.id}" entity = ViCareClimate( - f"{name} Heating{suffix}", + f"Heating{suffix}", api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], @@ -134,7 +133,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareClimate(ClimateEntity): +class ViCareClimate(ViCareEntity, ClimateEntity): """Representation of the ViCare heating climate device.""" _attr_precision = PRECISION_TENTHS @@ -146,24 +145,18 @@ class ViCareClimate(ClimateEntity): _attr_max_temp = VICARE_TEMP_HEATING_MAX _attr_target_temperature_step = PRECISION_WHOLE _attr_preset_modes = list(HA_TO_VICARE_PRESET_HEATING) + _current_action: bool | None = None + _current_mode: str | None = None - def __init__(self, name, api, circuit, device_config): + def __init__(self, name, api, circuit, device_config) -> None: """Initialize the climate device.""" + super().__init__(device_config) self._attr_name = name self._api = api self._circuit = circuit - self._attributes = {} - self._current_mode = None + self._attributes: dict[str, Any] = {} self._current_program = None - self._current_action = None self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_config.getConfig().serial)}, - name=device_config.getModel(), - manufacturer="Viessmann", - model=device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" @@ -237,7 +230,9 @@ class ViCareClimate(ClimateEntity): @property def hvac_mode(self) -> HVACMode | None: """Return current hvac mode.""" - return VICARE_TO_HA_HVAC_HEATING.get(self._current_mode) + if self._current_mode is None: + return None + return VICARE_TO_HA_HVAC_HEATING.get(self._current_mode, None) def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set a new hvac mode on the ViCare API.""" diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index a0feb8f38ea..5b2d3afa427 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -4,7 +4,10 @@ from __future__ import annotations import logging from typing import Any -from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) import voluptuous as vol from homeassistant import config_entries @@ -53,7 +56,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job( vicare_login, self.hass, user_input ) - except PyViCareInvalidCredentialsError: + except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError): errors["base"] = "invalid_auth" else: return self.async_create_entry(title=VICARE_NAME, data=user_input) diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index c3bd3037d96..546f18985e8 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -14,6 +14,7 @@ PLATFORMS = [ ] VICARE_DEVICE_CONFIG = "device_conf" +VICARE_DEVICE_CONFIG_LIST = "device_config_list" VICARE_API = "api" VICARE_NAME = "ViCare" diff --git a/homeassistant/components/vicare/diagnostics.py b/homeassistant/components/vicare/diagnostics.py index b4c0032037c..aa5d08f92d8 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN, VICARE_DEVICE_CONFIG +from .const import DOMAIN, VICARE_DEVICE_CONFIG_LIST TO_REDACT = {CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME} @@ -19,10 +19,9 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" # Currently we only support a single device - device = hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] - data: dict[str, Any] = json.loads( - await hass.async_add_executor_job(device.dump_secure) - ) + data = [] + for device in hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST]: + data.append(json.loads(await hass.async_add_executor_job(device.dump_secure))) return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), "data": data, diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py new file mode 100644 index 00000000000..089f9c062b8 --- /dev/null +++ b/homeassistant/components/vicare/entity.py @@ -0,0 +1,23 @@ +"""Entities for the ViCare integration.""" +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class ViCareEntity(Entity): + """Base class for ViCare entities.""" + + _attr_has_entity_name = True + + def __init__(self, device_config) -> None: + """Initialize the entity.""" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + serial_number=device_config.getConfig().serial, + name=device_config.getModel(), + manufacturer="Viessmann", + model=device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 418172975d8..e8bc4178073 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.25.0"] + "requirements": ["PyViCare==2.28.1"] } diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index d7ac7f25274..325f3bf2d07 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass import logging from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -30,7 +31,6 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin @@ -40,9 +40,10 @@ from .const import ( VICARE_CUBIC_METER, VICARE_DEVICE_CONFIG, VICARE_KWH, - VICARE_NAME, VICARE_UNIT_TO_UNIT_OF_MEASUREMENT, ) +from .entity import ViCareEntity +from .utils import is_supported _LOGGER = logging.getLogger(__name__) @@ -574,30 +575,31 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ) -def _build_entity(name, vicare_api, device_config, sensor): +def _build_entity( + name: str, + vicare_api, + device_config: PyViCareDeviceConfig, + entity_description: ViCareSensorEntityDescription, +): """Create a ViCare sensor entity.""" _LOGGER.debug("Found device %s", name) - try: - sensor.value_getter(vicare_api) - _LOGGER.debug("Found entity %s", name) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return None - except AttributeError: - _LOGGER.debug("Attribute Error %s", name) - return None - - return ViCareSensor( - name, - vicare_api, - device_config, - sensor, - ) + if is_supported(name, entity_description, vicare_api): + return ViCareSensor( + name, + vicare_api, + device_config, + entity_description, + ) + return None async def _entities_from_descriptions( - hass, name, entities, sensor_descriptions, iterables, config_entry -): + hass: HomeAssistant, + entities: list[ViCareSensor], + sensor_descriptions: tuple[ViCareSensorEntityDescription, ...], + iterables, + config_entry: ConfigEntry, +) -> None: """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: for current in iterables: @@ -606,7 +608,7 @@ async def _entities_from_descriptions( suffix = f" {current.id}" entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}{suffix}", + f"{description.name}{suffix}", current, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -621,14 +623,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - name = VICARE_NAME api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] entities = [] for description in GLOBAL_SENSORS: entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}", + description.name, api, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -638,21 +639,21 @@ async def async_setup_entry( try: await _entities_from_descriptions( - hass, name, entities, CIRCUIT_SENSORS, api.circuits, config_entry + hass, entities, CIRCUIT_SENSORS, api.circuits, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No circuits found") try: await _entities_from_descriptions( - hass, name, entities, BURNER_SENSORS, api.burners, config_entry + hass, entities, BURNER_SENSORS, api.burners, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No burners found") try: await _entities_from_descriptions( - hass, name, entities, COMPRESSOR_SENSORS, api.compressors, config_entry + hass, entities, COMPRESSOR_SENSORS, api.compressors, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No compressors found") @@ -660,7 +661,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareSensor(SensorEntity): +class ViCareSensor(ViCareEntity, SensorEntity): """Representation of a ViCare sensor.""" entity_description: ViCareSensorEntityDescription @@ -669,22 +670,12 @@ class ViCareSensor(SensorEntity): self, name, api, device_config, description: ViCareSensorEntityDescription ) -> None: """Initialize the sensor.""" + super().__init__(device_config) self.entity_description = description self._attr_name = name self._api = api self._device_config = device_config - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), - manufacturer="Viessmann", - model=self._device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) - @property def available(self): """Return True if entity is available.""" diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 0700d5d6f0e..056a4df7920 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -3,11 +3,11 @@ "flow_title": "{name} ({host})", "step": { "user": { - "description": "Set up ViCare integration. To generate API key go to https://developer.viessmann.com", + "description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", - "client_id": "[%key:common::config_flow::data::api_key%]", + "client_id": "Client ID", "heating_type": "Heating type" } } diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py new file mode 100644 index 00000000000..19a75c00962 --- /dev/null +++ b/homeassistant/components/vicare/utils.py @@ -0,0 +1,26 @@ +"""ViCare helpers functions.""" +import logging + +from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError + +from . import ViCareRequiredKeysMixin + +_LOGGER = logging.getLogger(__name__) + + +def is_supported( + name: str, + entity_description: ViCareRequiredKeysMixin, + vicare_device, +) -> bool: + """Check if the PyViCare device supports the requested sensor.""" + try: + entity_description.value_getter(vicare_device) + _LOGGER.debug("Found entity %s", name) + except PyViCareNotSupportedFeatureError: + _LOGGER.info("Feature not supported %s", name) + return False + except AttributeError as error: + _LOGGER.debug("Attribute Error %s: %s", name, error) + return False + return True diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 3357d2e0a31..db8a959f4ae 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -17,10 +17,10 @@ from homeassistant.components.water_heater import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -69,7 +69,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - name = VICARE_NAME entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] circuits = await hass.async_add_executor_job(_get_circuits, api) @@ -80,7 +79,7 @@ async def async_setup_entry( suffix = f" {circuit.id}" entity = ViCareWater( - f"{name} Water{suffix}", + f"Water{suffix}", api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], @@ -90,7 +89,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareWater(WaterHeaterEntity): +class ViCareWater(ViCareEntity, WaterHeaterEntity): """Representation of the ViCare domestic hot water device.""" _attr_precision = PRECISION_TENTHS @@ -100,21 +99,15 @@ class ViCareWater(WaterHeaterEntity): _attr_max_temp = VICARE_TEMP_WATER_MAX _attr_operation_list = list(HA_TO_VICARE_HVAC_DHW) - def __init__(self, name, api, circuit, device_config): + def __init__(self, name, api, circuit, device_config) -> None: """Initialize the DHW water_heater device.""" + super().__init__(device_config) self._attr_name = name self._api = api self._circuit = circuit - self._attributes = {} + self._attributes: dict[str, Any] = {} self._current_mode = None self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_config.getConfig().serial)}, - name=device_config.getModel(), - manufacturer="Viessmann", - model=device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" diff --git a/homeassistant/components/vlc/manifest.json b/homeassistant/components/vlc/manifest.json index 971367763fc..7e4fb7b2a4f 100644 --- a/homeassistant/components/vlc/manifest.json +++ b/homeassistant/components/vlc/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/vlc", "iot_class": "local_polling", - "requirements": ["python-vlc==1.1.2"] + "requirements": ["python-vlc==3.0.18122"] } diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 45bb263d371..dc33d0db52b 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from aiovodafone import VodafoneStationApi, exceptions as aiovodafone_exceptions +from aiovodafone import VodafoneStationSercommApi, exceptions as aiovodafone_exceptions import voluptuous as vol from homeassistant import core @@ -35,7 +35,9 @@ async def validate_input( ) -> dict[str, str]: """Validate the user input allows us to connect.""" - api = VodafoneStationApi(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) + api = VodafoneStationSercommApi( + data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] + ) try: await api.login() diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index fe1ff1889d5..a2cddcf9a65 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any -from aiovodafone import VodafoneStationApi, VodafoneStationDevice, exceptions +from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME from homeassistant.core import HomeAssistant @@ -48,7 +48,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Initialize the scanner.""" self._host = host - self.api = VodafoneStationApi(host, username, password) + self.api = VodafoneStationSercommApi(host, username, password) # Last resort as no MAC or S/N can be retrieved via API self._id = config_entry_unique_id @@ -95,15 +95,19 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Update router data.""" _LOGGER.debug("Polling Vodafone Station host: %s", self._host) try: - logged = await self.api.login() - except exceptions.CannotConnect as err: - _LOGGER.warning("Connection error for %s", self._host) - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err - except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err - - if not logged: - raise ConfigEntryAuthFailed + try: + await self.api.login() + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed from err + except ( + exceptions.CannotConnect, + exceptions.AlreadyLogged, + exceptions.GenericLoginError, + ) as err: + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except (ConfigEntryAuthFailed, UpdateFailed): + await self.api.close() + raise utc_point_in_time = dt_util.utcnow() data_devices = { diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index d37fed9564f..2a1814c83d0 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.3.1"] + "requirements": ["aiovodafone==0.4.2"] } diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index ce2d3154de3..1bda3b1595d 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime from typing import Any, Final from homeassistant.components.sensor import ( @@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.dt import utcnow from .const import _LOGGER, DOMAIN, LINE_TYPES from .coordinator import VodafoneStationRouter @@ -29,7 +28,9 @@ NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] class VodafoneStationBaseEntityDescription: """Vodafone Station entity base description.""" - value: Callable[[Any, Any], Any] = lambda val, key: val[key] + value: Callable[ + [Any, Any], Any + ] = lambda coordinator, key: coordinator.data.sensors[key] is_suitable: Callable[[dict], bool] = lambda val: True @@ -40,18 +41,16 @@ class VodafoneStationEntityDescription( """Vodafone Station entity description.""" -def _calculate_uptime(value: dict, key: str) -> datetime: +def _calculate_uptime(coordinator: VodafoneStationRouter, key: str) -> datetime: """Calculate device uptime.""" - d = int(value[key].split(":")[0]) - h = int(value[key].split(":")[1]) - m = int(value[key].split(":")[2]) - return utcnow() - timedelta(days=d, hours=h, minutes=m) + return coordinator.api.convert_uptime(coordinator.data.sensors[key]) -def _line_connection(value: dict, key: str) -> str | None: +def _line_connection(coordinator: VodafoneStationRouter, key: str) -> str | None: """Identify line type.""" + value = coordinator.data.sensors internet_ip = value[key] dsl_ip = value.get("dsl_ipaddr") fiber_ip = value.get("fiber_ipaddr") @@ -141,7 +140,7 @@ SENSOR_TYPES: Final = ( icon="mdi:chip", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - value=lambda value, key: float(value[key][:-1]), + value=lambda coordinator, key: float(coordinator.data.sensors[key][:-1]), ), VodafoneStationEntityDescription( key="sys_memory_usage", @@ -149,7 +148,7 @@ SENSOR_TYPES: Final = ( icon="mdi:memory", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - value=lambda value, key: float(value[key][:-1]), + value=lambda coordinator, key: float(coordinator.data.sensors[key][:-1]), ), VodafoneStationEntityDescription( key="sys_reboot_cause", @@ -200,5 +199,5 @@ class VodafoneStationSensorEntity( def native_value(self) -> StateType: """Sensor value.""" return self.entity_description.value( - self.coordinator.data.sensors, self.entity_description.key + self.coordinator, self.entity_description.key ) diff --git a/homeassistant/components/vulcan/__init__.py b/homeassistant/components/vulcan/__init__.py index 0bfd09d590d..b52b4181510 100644 --- a/homeassistant/components/vulcan/__init__.py +++ b/homeassistant/components/vulcan/__init__.py @@ -1,21 +1,32 @@ """The Vulcan component.""" +import sys from aiohttp import ClientConnectorError -from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +if sys.version_info < (3, 12): + from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan + PLATFORMS = [Platform.CALENDAR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Uonet+ Vulcan integration.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Uonet+ Vulcan is not supported on Python 3.12. Please use Python 3.11." + ) hass.data.setdefault(DOMAIN, {}) try: keystore = Keystore.load(entry.data["keystore"]) diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index 20c8ff78432..073ac88fbda 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta import logging +from typing import cast from zoneinfo import ZoneInfo from aiohttp import ClientConnectorError @@ -56,26 +57,27 @@ async def async_setup_entry( class VulcanCalendarEntity(CalendarEntity): """A calendar entity.""" + _attr_has_entity_name = True + _attr_translation_key = "calendar" + def __init__(self, client, data, entity_id) -> None: """Create the Calendar entity.""" - self.student_info = data["student_info"] self._event: CalendarEvent | None = None self.client = client self.entity_id = entity_id - self._unique_id = f"vulcan_calendar_{self.student_info['id']}" - self._attr_name = f"Vulcan calendar - {self.student_info['full_name']}" - self._attr_unique_id = f"vulcan_calendar_{self.student_info['id']}" + student_info = data["student_info"] + self._attr_unique_id = f"vulcan_calendar_{student_info['id']}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"calendar_{self.student_info['id']}")}, + identifiers={(DOMAIN, f"calendar_{student_info['id']}")}, entry_type=DeviceEntryType.SERVICE, - name=f"{self.student_info['full_name']}: Calendar", + name=cast(str, student_info["full_name"]), model=( - f"{self.student_info['full_name']} -" - f" {self.student_info['class']} {self.student_info['school']}" + f"{student_info['full_name']} -" + f" {student_info['class']} {student_info['school']}" ), manufacturer="Uonet +", configuration_url=( - f"https://uonetplus.vulcan.net.pl/{self.student_info['symbol']}" + f"https://uonetplus.vulcan.net.pl/{student_info['symbol']}" ), ) diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index 07a0510f482..814621b5403 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -4,7 +4,7 @@ "already_configured": "That student has already been added.", "all_student_already_configured": "All students have already been added.", "reauth_successful": "Reauth successful", - "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student.." + "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student." }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", @@ -29,7 +29,7 @@ "data": { "token": "Token", "region": "[%key:component::vulcan::config::step::auth::data::region%]", - "pin": "[%key:component::vulcan::config::step::auth::data::pin%]" + "pin": "[%key:common::config_flow::data::pin%]" } }, "select_student": { @@ -51,5 +51,12 @@ } } } + }, + "entity": { + "calendar": { + "calendar": { + "name": "[%key:component::calendar::title%]" + } + } } } diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 4db217d0a54..8194a3ea262 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -1,205 +1,17 @@ """The Wallbox integration.""" from __future__ import annotations -from datetime import timedelta -from http import HTTPStatus -import logging -from typing import Any - -import requests from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import ( - CHARGER_CURRENCY_KEY, - CHARGER_CURRENT_VERSION_KEY, - CHARGER_DATA_KEY, - CHARGER_ENERGY_PRICE_KEY, - CHARGER_LOCKED_UNLOCKED_KEY, - CHARGER_MAX_CHARGING_CURRENT_KEY, - CHARGER_NAME_KEY, - CHARGER_PART_NUMBER_KEY, - CHARGER_SERIAL_NUMBER_KEY, - CHARGER_SOFTWARE_KEY, - CHARGER_STATUS_DESCRIPTION_KEY, - CHARGER_STATUS_ID_KEY, - CODE_KEY, - CONF_STATION, - DOMAIN, - ChargerStatus, -) - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_STATION, DOMAIN, UPDATE_INTERVAL +from .coordinator import InvalidAuth, WallboxCoordinator PLATFORMS = [Platform.SENSOR, Platform.NUMBER, Platform.LOCK, Platform.SWITCH] -UPDATE_INTERVAL = 30 - -# Translation of StatusId based on Wallbox portal code: -# https://my.wallbox.com/src/utilities/charger/chargerStatuses.js -CHARGER_STATUS: dict[int, ChargerStatus] = { - 0: ChargerStatus.DISCONNECTED, - 14: ChargerStatus.ERROR, - 15: ChargerStatus.ERROR, - 161: ChargerStatus.READY, - 162: ChargerStatus.READY, - 163: ChargerStatus.DISCONNECTED, - 164: ChargerStatus.WAITING, - 165: ChargerStatus.LOCKED, - 166: ChargerStatus.UPDATING, - 177: ChargerStatus.SCHEDULED, - 178: ChargerStatus.PAUSED, - 179: ChargerStatus.SCHEDULED, - 180: ChargerStatus.WAITING_FOR_CAR, - 181: ChargerStatus.WAITING_FOR_CAR, - 182: ChargerStatus.PAUSED, - 183: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, - 184: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, - 185: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, - 186: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, - 187: ChargerStatus.WAITING_MID_FAILED, - 188: ChargerStatus.WAITING_MID_SAFETY, - 189: ChargerStatus.WAITING_IN_QUEUE_ECO_SMART, - 193: ChargerStatus.CHARGING, - 194: ChargerStatus.CHARGING, - 195: ChargerStatus.CHARGING, - 196: ChargerStatus.DISCHARGING, - 209: ChargerStatus.LOCKED, - 210: ChargerStatus.LOCKED_CAR_CONNECTED, -} - - -class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Wallbox Coordinator class.""" - - def __init__(self, station: str, wallbox: Wallbox, hass: HomeAssistant) -> None: - """Initialize.""" - self._station = station - self._wallbox = wallbox - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), - ) - - def _authenticate(self) -> None: - """Authenticate using Wallbox API.""" - try: - self._wallbox.authenticate() - - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: - raise ConfigEntryAuthFailed from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - def _validate(self) -> None: - """Authenticate using Wallbox API.""" - try: - self._wallbox.authenticate() - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - async def async_validate_input(self) -> None: - """Get new sensor data for Wallbox component.""" - await self.hass.async_add_executor_job(self._validate) - - def _get_data(self) -> dict[str, Any]: - """Get new sensor data for Wallbox component.""" - try: - self._authenticate() - data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) - data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_CHARGING_CURRENT_KEY - ] - data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_LOCKED_UNLOCKED_KEY - ] - data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_ENERGY_PRICE_KEY - ] - data[ - CHARGER_CURRENCY_KEY - ] = f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" - - data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( - data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN - ) - return data - except ( - ConnectionError, - requests.exceptions.HTTPError, - ) as wallbox_connection_error: - raise UpdateFailed from wallbox_connection_error - - async def _async_update_data(self) -> dict[str, Any]: - """Get new sensor data for Wallbox component.""" - return await self.hass.async_add_executor_job(self._get_data) - - def _set_charging_current(self, charging_current: float) -> None: - """Set maximum charging current for Wallbox.""" - try: - self._authenticate() - self._wallbox.setMaxChargingCurrent(self._station, charging_current) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - async def async_set_charging_current(self, charging_current: float) -> None: - """Set maximum charging current for Wallbox.""" - await self.hass.async_add_executor_job( - self._set_charging_current, charging_current - ) - await self.async_request_refresh() - - def _set_lock_unlock(self, lock: bool) -> None: - """Set wallbox to locked or unlocked.""" - try: - self._authenticate() - if lock: - self._wallbox.lockCharger(self._station) - else: - self._wallbox.unlockCharger(self._station) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - async def async_set_lock_unlock(self, lock: bool) -> None: - """Set wallbox to locked or unlocked.""" - await self.hass.async_add_executor_job(self._set_lock_unlock, lock) - await self.async_request_refresh() - - def _pause_charger(self, pause: bool) -> None: - """Set wallbox to pause or resume.""" - try: - self._authenticate() - if pause: - self._wallbox.pauseChargingSession(self._station) - else: - self._wallbox.resumeChargingSession(self._station) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - async def async_pause_charger(self, pause: bool) -> None: - """Set wallbox to pause or resume.""" - await self.hass.async_add_executor_job(self._pause_charger, pause) - await self.async_request_refresh() async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -237,31 +49,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): - """Defines a base Wallbox entity.""" - - _attr_has_entity_name = True - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this Wallbox device.""" - return DeviceInfo( - identifiers={ - ( - DOMAIN, - self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY], - ) - }, - name=f"Wallbox {self.coordinator.data[CHARGER_NAME_KEY]}", - manufacturer="Wallbox", - model=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY], - sw_version=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SOFTWARE_KEY][ - CHARGER_CURRENT_VERSION_KEY - ], - ) diff --git a/homeassistant/components/wallbox/config_flow.py b/homeassistant/components/wallbox/config_flow.py index 85f5d02ba99..0f3782958d3 100644 --- a/homeassistant/components/wallbox/config_flow.py +++ b/homeassistant/components/wallbox/config_flow.py @@ -11,8 +11,8 @@ from homeassistant import config_entries, core from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult -from . import InvalidAuth, WallboxCoordinator from .const import CONF_STATION, DOMAIN +from .coordinator import InvalidAuth, WallboxCoordinator COMPONENT_DOMAIN = DOMAIN diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index eec7bb4e8da..6caa3c070c8 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -2,6 +2,7 @@ from enum import StrEnum DOMAIN = "wallbox" +UPDATE_INTERVAL = 30 BIDIRECTIONAL_MODEL_PREFIXES = ["QS"] diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py new file mode 100644 index 00000000000..b9248d8ce5b --- /dev/null +++ b/homeassistant/components/wallbox/coordinator.py @@ -0,0 +1,206 @@ +"""DataUpdateCoordinator for the wallbox integration.""" +from __future__ import annotations + +from datetime import timedelta +from http import HTTPStatus +import logging +from typing import Any + +import requests +from wallbox import Wallbox + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CHARGER_CURRENCY_KEY, + CHARGER_DATA_KEY, + CHARGER_ENERGY_PRICE_KEY, + CHARGER_LOCKED_UNLOCKED_KEY, + CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_STATUS_DESCRIPTION_KEY, + CHARGER_STATUS_ID_KEY, + CODE_KEY, + DOMAIN, + UPDATE_INTERVAL, + ChargerStatus, +) + +_LOGGER = logging.getLogger(__name__) + +# Translation of StatusId based on Wallbox portal code: +# https://my.wallbox.com/src/utilities/charger/chargerStatuses.js +CHARGER_STATUS: dict[int, ChargerStatus] = { + 0: ChargerStatus.DISCONNECTED, + 14: ChargerStatus.ERROR, + 15: ChargerStatus.ERROR, + 161: ChargerStatus.READY, + 162: ChargerStatus.READY, + 163: ChargerStatus.DISCONNECTED, + 164: ChargerStatus.WAITING, + 165: ChargerStatus.LOCKED, + 166: ChargerStatus.UPDATING, + 177: ChargerStatus.SCHEDULED, + 178: ChargerStatus.PAUSED, + 179: ChargerStatus.SCHEDULED, + 180: ChargerStatus.WAITING_FOR_CAR, + 181: ChargerStatus.WAITING_FOR_CAR, + 182: ChargerStatus.PAUSED, + 183: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, + 184: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, + 185: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, + 186: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, + 187: ChargerStatus.WAITING_MID_FAILED, + 188: ChargerStatus.WAITING_MID_SAFETY, + 189: ChargerStatus.WAITING_IN_QUEUE_ECO_SMART, + 193: ChargerStatus.CHARGING, + 194: ChargerStatus.CHARGING, + 195: ChargerStatus.CHARGING, + 196: ChargerStatus.DISCHARGING, + 209: ChargerStatus.LOCKED, + 210: ChargerStatus.LOCKED_CAR_CONNECTED, +} + + +class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Wallbox Coordinator class.""" + + def __init__(self, station: str, wallbox: Wallbox, hass: HomeAssistant) -> None: + """Initialize.""" + self._station = station + self._wallbox = wallbox + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + def _authenticate(self) -> None: + """Authenticate using Wallbox API.""" + try: + self._wallbox.authenticate() + + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: + raise ConfigEntryAuthFailed from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + def _validate(self) -> None: + """Authenticate using Wallbox API.""" + try: + self._wallbox.authenticate() + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_validate_input(self) -> None: + """Get new sensor data for Wallbox component.""" + await self.hass.async_add_executor_job(self._validate) + + def _get_data(self) -> dict[str, Any]: + """Get new sensor data for Wallbox component.""" + try: + self._authenticate() + data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_CHARGING_CURRENT_KEY + ] + data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_LOCKED_UNLOCKED_KEY + ] + data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_ENERGY_PRICE_KEY + ] + data[ + CHARGER_CURRENCY_KEY + ] = f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" + + data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( + data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN + ) + return data + except ( + ConnectionError, + requests.exceptions.HTTPError, + ) as wallbox_connection_error: + raise UpdateFailed from wallbox_connection_error + + async def _async_update_data(self) -> dict[str, Any]: + """Get new sensor data for Wallbox component.""" + return await self.hass.async_add_executor_job(self._get_data) + + def _set_charging_current(self, charging_current: float) -> None: + """Set maximum charging current for Wallbox.""" + try: + self._authenticate() + self._wallbox.setMaxChargingCurrent(self._station, charging_current) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_set_charging_current(self, charging_current: float) -> None: + """Set maximum charging current for Wallbox.""" + await self.hass.async_add_executor_job( + self._set_charging_current, charging_current + ) + await self.async_request_refresh() + + def _set_energy_cost(self, energy_cost: float) -> None: + """Set energy cost for Wallbox.""" + try: + self._authenticate() + self._wallbox.setEnergyCost(self._station, energy_cost) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_set_energy_cost(self, energy_cost: float) -> None: + """Set energy cost for Wallbox.""" + await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) + await self.async_request_refresh() + + def _set_lock_unlock(self, lock: bool) -> None: + """Set wallbox to locked or unlocked.""" + try: + self._authenticate() + if lock: + self._wallbox.lockCharger(self._station) + else: + self._wallbox.unlockCharger(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_set_lock_unlock(self, lock: bool) -> None: + """Set wallbox to locked or unlocked.""" + await self.hass.async_add_executor_job(self._set_lock_unlock, lock) + await self.async_request_refresh() + + def _pause_charger(self, pause: bool) -> None: + """Set wallbox to pause or resume.""" + try: + self._authenticate() + if pause: + self._wallbox.pauseChargingSession(self._station) + else: + self._wallbox.resumeChargingSession(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_pause_charger(self, pause: bool) -> None: + """Set wallbox to pause or resume.""" + await self.hass.async_add_executor_job(self._pause_charger, pause) + await self.async_request_refresh() + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/wallbox/entity.py b/homeassistant/components/wallbox/entity.py new file mode 100644 index 00000000000..1152530dbd1 --- /dev/null +++ b/homeassistant/components/wallbox/entity.py @@ -0,0 +1,40 @@ +"""Base entity for the wallbox integration.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CHARGER_CURRENT_VERSION_KEY, + CHARGER_DATA_KEY, + CHARGER_NAME_KEY, + CHARGER_PART_NUMBER_KEY, + CHARGER_SERIAL_NUMBER_KEY, + CHARGER_SOFTWARE_KEY, + DOMAIN, +) +from .coordinator import WallboxCoordinator + + +class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): + """Defines a base Wallbox entity.""" + + _attr_has_entity_name = True + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Wallbox device.""" + return DeviceInfo( + identifiers={ + ( + DOMAIN, + self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY], + ) + }, + name=f"Wallbox {self.coordinator.data[CHARGER_NAME_KEY]}", + manufacturer="Wallbox", + model=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY], + sw_version=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SOFTWARE_KEY][ + CHARGER_CURRENT_VERSION_KEY + ], + ) diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 04a587ae34d..11a66a4814c 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -9,13 +9,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import InvalidAuth, WallboxCoordinator, WallboxEntity from .const import ( CHARGER_DATA_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) +from .coordinator import InvalidAuth, WallboxCoordinator +from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { CHARGER_LOCKED_UNLOCKED_KEY: LockEntityDescription( diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index fc63e0ca25f..a6e284ff22b 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.4.12"] + "requirements": ["wallbox==0.4.14"] } diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index dff723c579b..9694e13103c 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -4,6 +4,7 @@ The number component allows control of charging current. """ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import cast @@ -13,20 +14,43 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import InvalidAuth, WallboxCoordinator, WallboxEntity from .const import ( BIDIRECTIONAL_MODEL_PREFIXES, CHARGER_DATA_KEY, + CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_AVAILABLE_POWER_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, CHARGER_PART_NUMBER_KEY, CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) +from .coordinator import InvalidAuth, WallboxCoordinator +from .entity import WallboxEntity + + +def min_charging_current_value(coordinator: WallboxCoordinator) -> float: + """Return the minimum available value for charging current.""" + if ( + coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:2] + in BIDIRECTIONAL_MODEL_PREFIXES + ): + return cast(float, (coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] * -1)) + return 0 @dataclass -class WallboxNumberEntityDescription(NumberEntityDescription): +class WallboxNumberEntityDescriptionMixin: + """Load entities from different handlers.""" + + max_value_fn: Callable[[WallboxCoordinator], float] + min_value_fn: Callable[[WallboxCoordinator], float] + set_value_fn: Callable[[WallboxCoordinator], Callable[[float], Awaitable[None]]] + + +@dataclass +class WallboxNumberEntityDescription( + NumberEntityDescription, WallboxNumberEntityDescriptionMixin +): """Describes Wallbox number entity.""" @@ -34,6 +58,20 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, translation_key="maximum_charging_current", + max_value_fn=lambda coordinator: cast( + float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] + ), + min_value_fn=min_charging_current_value, + set_value_fn=lambda coordinator: coordinator.async_set_charging_current, + native_step=1, + ), + CHARGER_ENERGY_PRICE_KEY: WallboxNumberEntityDescription( + key=CHARGER_ENERGY_PRICE_KEY, + translation_key="energy_price", + max_value_fn=lambda _: 5, + min_value_fn=lambda _: -5, + set_value_fn=lambda coordinator: coordinator.async_set_energy_cost, + native_step=0.01, ), } @@ -43,7 +81,7 @@ async def async_setup_entry( ) -> None: """Create wallbox number entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user is authorized to change current, if so, add number component: + # Check if the user has sufficient rights to change values, if so, add number component: try: await coordinator.async_set_charging_current( coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] @@ -78,28 +116,22 @@ class WallboxNumber(WallboxEntity, NumberEntity): self.entity_description = description self._coordinator = coordinator self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" - self._is_bidirectional = ( - coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:2] - in BIDIRECTIONAL_MODEL_PREFIXES - ) @property def native_max_value(self) -> float: - """Return the maximum available current.""" - return cast(float, self._coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY]) + """Return the maximum available value.""" + return self.entity_description.max_value_fn(self.coordinator) @property def native_min_value(self) -> float: - """Return the minimum available current based on charger type - some chargers can discharge.""" - return (self.max_value * -1) if self._is_bidirectional else 6 + """Return the minimum available value.""" + return self.entity_description.min_value_fn(self.coordinator) @property def native_value(self) -> float | None: """Return the value of the entity.""" - return cast( - float | None, self._coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] - ) + return cast(float | None, self._coordinator.data[self.entity_description.key]) async def async_set_native_value(self, value: float) -> None: """Set the value of the entity.""" - await self._coordinator.async_set_charging_current(value) + await self.entity_description.set_value_fn(self.coordinator)(value) diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 56d9e0be735..4a1cf365bb1 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import WallboxCoordinator, WallboxEntity from .const import ( CHARGER_ADDED_DISCHARGED_ENERGY_KEY, CHARGER_ADDED_ENERGY_KEY, @@ -43,6 +42,8 @@ from .const import ( CHARGER_STATUS_DESCRIPTION_KEY, DOMAIN, ) +from .coordinator import WallboxCoordinator +from .entity import WallboxEntity CHARGER_STATION = "station" UPDATE_INTERVAL = 30 diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 69db4bb97e3..dd96cebf605 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -35,6 +35,9 @@ "number": { "maximum_charging_current": { "name": "Maximum charging current" + }, + "energy_price": { + "name": "Energy price" } }, "sensor": { diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index b101ffe1c09..2de6379eb18 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -8,7 +8,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import WallboxCoordinator, WallboxEntity from .const import ( CHARGER_DATA_KEY, CHARGER_PAUSE_RESUME_KEY, @@ -17,6 +16,8 @@ from .const import ( DOMAIN, ChargerStatus, ) +from .coordinator import WallboxCoordinator +from .entity import WallboxEntity SWITCH_TYPES: dict[str, SwitchEntityDescription] = { CHARGER_PAUSE_RESUME_KEY: SwitchEntityDescription( diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index 8404b425678..55740913487 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -1,7 +1,6 @@ """Config flow for World Air Quality Index (WAQI) integration.""" from __future__ import annotations -from collections.abc import Awaitable, Callable import logging from typing import Any @@ -40,6 +39,22 @@ _LOGGER = logging.getLogger(__name__) CONF_MAP = "map" +async def get_by_station_number( + client: WAQIClient, station_number: int +) -> tuple[WAQIAirQuality | None, dict[str, str]]: + """Get measuring station by station number.""" + errors: dict[str, str] = {} + measuring_station: WAQIAirQuality | None = None + try: + measuring_station = await client.get_by_station_number(station_number) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + errors["base"] = "unknown" + return measuring_station, errors + + class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for World Air Quality Index (WAQI).""" @@ -90,13 +105,10 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_base_step( - self, - step_id: str, - method: Callable[[WAQIClient, dict[str, Any]], Awaitable[WAQIAirQuality]], - data_schema: vol.Schema, - user_input: dict[str, Any] | None = None, + async def async_step_map( + self, user_input: dict[str, Any] | None = None ) -> FlowResult: + """Add measuring station via map.""" errors: dict[str, str] = {} if user_input is not None: async with WAQIClient( @@ -104,7 +116,10 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): ) as waqi_client: waqi_client.authenticate(self.data[CONF_API_KEY]) try: - measuring_station = await method(waqi_client, user_input) + measuring_station = await waqi_client.get_by_coordinates( + user_input[CONF_LOCATION][CONF_LATITUDE], + user_input[CONF_LOCATION][CONF_LONGITUDE], + ) except WAQIConnectionError: errors["base"] = "cannot_connect" except Exception as exc: # pylint: disable=broad-except @@ -113,19 +128,8 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): else: return await self._async_create_entry(measuring_station) return self.async_show_form( - step_id=step_id, data_schema=data_schema, errors=errors - ) - - async def async_step_map( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Add measuring station via map.""" - return await self._async_base_step( - CONF_MAP, - lambda waqi_client, data: waqi_client.get_by_coordinates( - data[CONF_LOCATION][CONF_LATITUDE], data[CONF_LOCATION][CONF_LONGITUDE] - ), - self.add_suggested_values_to_schema( + step_id=CONF_MAP, + data_schema=self.add_suggested_values_to_schema( vol.Schema( { vol.Required( @@ -140,26 +144,40 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): } }, ), - user_input, + errors=errors, ) async def async_step_station_number( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Add measuring station via station number.""" - return await self._async_base_step( - CONF_STATION_NUMBER, - lambda waqi_client, data: waqi_client.get_by_station_number( - data[CONF_STATION_NUMBER] - ), - vol.Schema( + errors: dict[str, str] = {} + if user_input is not None: + async with WAQIClient( + session=async_get_clientsession(self.hass) + ) as waqi_client: + waqi_client.authenticate(self.data[CONF_API_KEY]) + station_number = user_input[CONF_STATION_NUMBER] + measuring_station, errors = await get_by_station_number( + waqi_client, abs(station_number) + ) + if not measuring_station: + measuring_station, _ = await get_by_station_number( + waqi_client, + abs(station_number) - station_number - station_number, + ) + if measuring_station: + return await self._async_create_entry(measuring_station) + return self.async_show_form( + step_id=CONF_STATION_NUMBER, + data_schema=vol.Schema( { vol.Required( CONF_STATION_NUMBER, ): int, } ), - user_input, + errors=errors, ) async def _async_create_entry( diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 62170b329f4..d94a2e19f67 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,32 +1,45 @@ """Support for the World Air Quality Index service.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from dataclasses import dataclass import logging +from typing import Any -from aiowaqi import WAQIAuthenticationError, WAQIClient, WAQIConnectionError +from aiowaqi import ( + WAQIAirQuality, + WAQIAuthenticationError, + WAQIClient, + WAQIConnectionError, +) +from aiowaqi.models import Pollutant import voluptuous as vol from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_TEMPERATURE, ATTR_TIME, CONF_API_KEY, CONF_NAME, CONF_TOKEN, + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER @@ -140,61 +153,175 @@ async def async_setup_platform( ) +@dataclass +class WAQIMixin: + """Mixin for required keys.""" + + available_fn: Callable[[WAQIAirQuality], bool] + value_fn: Callable[[WAQIAirQuality], StateType] + + +@dataclass +class WAQISensorEntityDescription(SensorEntityDescription, WAQIMixin): + """Describes WAQI sensor entity.""" + + +SENSORS: list[WAQISensorEntityDescription] = [ + WAQISensorEntityDescription( + key="air_quality", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.air_quality_index, + available_fn=lambda _: True, + ), + WAQISensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.humidity, + available_fn=lambda aq: aq.extended_air_quality.humidity is not None, + ), + WAQISensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.pressure, + available_fn=lambda aq: aq.extended_air_quality.pressure is not None, + ), + WAQISensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.temperature, + available_fn=lambda aq: aq.extended_air_quality.temperature is not None, + ), + WAQISensorEntityDescription( + key="carbon_monoxide", + translation_key="carbon_monoxide", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.carbon_monoxide, + available_fn=lambda aq: aq.extended_air_quality.carbon_monoxide is not None, + ), + WAQISensorEntityDescription( + key="nitrogen_dioxide", + translation_key="nitrogen_dioxide", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.nitrogen_dioxide, + available_fn=lambda aq: aq.extended_air_quality.nitrogen_dioxide is not None, + ), + WAQISensorEntityDescription( + key="ozone", + translation_key="ozone", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.ozone, + available_fn=lambda aq: aq.extended_air_quality.ozone is not None, + ), + WAQISensorEntityDescription( + key="sulphur_dioxide", + translation_key="sulphur_dioxide", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.sulfur_dioxide, + available_fn=lambda aq: aq.extended_air_quality.sulfur_dioxide is not None, + ), + WAQISensorEntityDescription( + key="pm10", + translation_key="pm10", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.pm10, + available_fn=lambda aq: aq.extended_air_quality.pm10 is not None, + ), + WAQISensorEntityDescription( + key="pm25", + translation_key="pm25", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.pm25, + available_fn=lambda aq: aq.extended_air_quality.pm25 is not None, + ), + WAQISensorEntityDescription( + key="neph", + translation_key="neph", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.nephelometry, + available_fn=lambda aq: aq.extended_air_quality.nephelometry is not None, + entity_registry_enabled_default=False, + ), + WAQISensorEntityDescription( + key="dominant_pollutant", + translation_key="dominant_pollutant", + device_class=SensorDeviceClass.ENUM, + options=[pollutant.value for pollutant in Pollutant], + value_fn=lambda aq: aq.dominant_pollutant, + available_fn=lambda _: True, + ), +] + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the WAQI sensor.""" coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([WaqiSensor(coordinator)]) + async_add_entities( + [ + WaqiSensor(coordinator, sensor) + for sensor in SENSORS + if sensor.available_fn(coordinator.data) + ] + ) class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): """Implementation of a WAQI sensor.""" - _attr_icon = ATTR_ICON - _attr_device_class = SensorDeviceClass.AQI - _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + entity_description: WAQISensorEntityDescription - def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: WAQIDataUpdateCoordinator, + entity_description: WAQISensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_name = f"WAQI {self.coordinator.data.city.name}" - self._attr_unique_id = f"{coordinator.data.station_id}_air_quality" + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.data.station_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(coordinator.data.station_id))}, + name=coordinator.data.city.name, + entry_type=DeviceEntryType.SERVICE, + ) + self._attr_attribution = " and ".join( + attribution.name for attribution in coordinator.data.attributions + ) @property - def native_value(self) -> int | None: + def native_value(self) -> StateType: """Return the state of the device.""" - return self.coordinator.data.air_quality_index + return self.entity_description.value_fn(self.coordinator.data) @property - def extra_state_attributes(self): - """Return the state attributes of the last update.""" - attrs = {} - try: - attrs[ATTR_ATTRIBUTION] = " and ".join( - [ATTRIBUTION] - + [ - attribution.name - for attribution in self.coordinator.data.attributions - ] - ) + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return old state attributes if the entity is AQI entity.""" + if self.entity_description.key != "air_quality": + return None + attrs: dict[str, Any] = {} + attrs[ATTR_TIME] = self.coordinator.data.measured_at + attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant - attrs[ATTR_TIME] = self.coordinator.data.measured_at - attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant + iaqi = self.coordinator.data.extended_air_quality - iaqi = self.coordinator.data.extended_air_quality - - attribute = { - ATTR_PM2_5: iaqi.pm25, - ATTR_PM10: iaqi.pm10, - ATTR_HUMIDITY: iaqi.humidity, - ATTR_PRESSURE: iaqi.pressure, - ATTR_TEMPERATURE: iaqi.temperature, - ATTR_OZONE: iaqi.ozone, - ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, - ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, - } - res_attributes = {k: v for k, v in attribute.items() if v is not None} - return {**attrs, **res_attributes} - except (IndexError, KeyError): - return {ATTR_ATTRIBUTION: ATTRIBUTION} + attribute = { + ATTR_PM2_5: iaqi.pm25, + ATTR_PM10: iaqi.pm10, + ATTR_HUMIDITY: iaqi.humidity, + ATTR_PRESSURE: iaqi.pressure, + ATTR_TEMPERATURE: iaqi.temperature, + ATTR_OZONE: iaqi.ozone, + ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, + ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, + } + res_attributes = {k: v for k, v in attribute.items() if v is not None} + return {**attrs, **res_attributes} diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json index 46031a3072b..de287318508 100644 --- a/homeassistant/components/waqi/strings.json +++ b/homeassistant/components/waqi/strings.json @@ -53,5 +53,42 @@ "title": "The WAQI YAML configuration import failed", "description": "Configuring World Air Quality Index using YAML is being removed but there weren't any stations imported because they couldn't be found.\n\nEnsure the imported configuration is correct and remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } + }, + "entity": { + "sensor": { + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "nitrogen_dioxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, + "ozone": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + }, + "sulphur_dioxide": { + "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "neph": { + "name": "Visbility using nephelometry" + }, + "dominant_pollutant": { + "name": "Dominant pollutant", + "state": { + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "neph": "Nephelometry", + "no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]" + } + } + } } } diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 4ec9ea91f89..648201f16d2 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -8,7 +8,6 @@ from contextlib import suppress from dataclasses import dataclass from datetime import timedelta from functools import partial -import inspect import logging from typing import ( Any, @@ -56,6 +55,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, TimestampDataUpdateCoordinator, ) +from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue from homeassistant.util.dt import utcnow from homeassistant.util.json import JsonValueType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -296,7 +296,8 @@ class WeatherEntity(Entity, PostInit): Literal["daily", "hourly", "twice_daily"], list[Callable[[list[JsonValueType] | None], None]], ] - __weather_legacy_forecast: bool = False + __weather_reported_legacy_forecast = False + __weather_legacy_forecast = False _weather_option_temperature_unit: str | None = None _weather_option_pressure_unit: str | None = None @@ -311,15 +312,12 @@ class WeatherEntity(Entity, PostInit): def __init_subclass__(cls, **kwargs: Any) -> None: """Post initialisation processing.""" super().__init_subclass__(**kwargs) - if any( - method in cls.__dict__ for method in ("_attr_forecast", "forecast") - ) and not any( - method in cls.__dict__ - for method in ( - "async_forecast_daily", - "async_forecast_hourly", - "async_forecast_twice_daily", - ) + if ( + "forecast" in cls.__dict__ + and cls.async_forecast_daily is WeatherEntity.async_forecast_daily + and cls.async_forecast_hourly is WeatherEntity.async_forecast_hourly + and cls.async_forecast_twice_daily + is WeatherEntity.async_forecast_twice_daily ): cls.__weather_legacy_forecast = True @@ -332,38 +330,55 @@ class WeatherEntity(Entity, PostInit): ) -> None: """Start adding an entity to a platform.""" super().add_to_platform_start(hass, platform, parallel_updates) - _reported_forecast = False - if self.__weather_legacy_forecast and not _reported_forecast: - module = inspect.getmodule(self) - if module and module.__file__ and "custom_components" in module.__file__: - # Do not report on core integrations as they are already fixed or PR is open. - report_issue = "report it to the custom integration author." - _LOGGER.warning( - ( - "%s::%s is using a forecast attribute on an instance of " - "WeatherEntity, this is deprecated and will be unsupported " - "from Home Assistant 2024.3. Please %s" - ), - self.__module__, - self.entity_id, - report_issue, - ) - ir.async_create_issue( - self.hass, - DOMAIN, - f"deprecated_weather_forecast_{self.platform.platform_name}", - breaks_in_ha_version="2024.3.0", - is_fixable=False, - is_persistent=False, - issue_domain=self.platform.platform_name, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_weather_forecast", - translation_placeholders={ - "platform": self.platform.platform_name, - "report_issue": report_issue, - }, - ) - _reported_forecast = True + if self.__weather_legacy_forecast: + self._report_legacy_forecast(hass) + + def _report_legacy_forecast(self, hass: HomeAssistant) -> None: + """Log warning and create an issue if the entity imlpements legacy forecast.""" + if "custom_components" not in type(self).__module__: + # Do not report core integrations as they are already fixed or PR is open. + return + + report_issue = async_suggest_report_issue( + hass, + integration_domain=self.platform.platform_name, + module=type(self).__module__, + ) + _LOGGER.warning( + ( + "%s::%s implements the `forecast` property or sets " + "`self._attr_forecast` in a subclass of WeatherEntity, this is " + "deprecated and will be unsupported from Home Assistant 2024.3." + " Please %s" + ), + self.platform.platform_name, + self.__class__.__name__, + report_issue, + ) + + translation_placeholders = {"platform": self.platform.platform_name} + translation_key = "deprecated_weather_forecast_no_url" + issue_tracker = async_get_issue_tracker( + hass, + integration_domain=self.platform.platform_name, + module=type(self).__module__, + ) + if issue_tracker: + translation_placeholders["issue_tracker"] = issue_tracker + translation_key = "deprecated_weather_forecast_url" + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_weather_forecast_{self.platform.platform_name}", + breaks_in_ha_version="2024.3.0", + is_fixable=False, + is_persistent=False, + issue_domain=self.platform.platform_name, + severity=ir.IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + self.__weather_reported_legacy_forecast = True async def async_internal_added_to_hass(self) -> None: """Call when the weather entity is added to hass.""" @@ -554,6 +569,15 @@ class WeatherEntity(Entity, PostInit): Should not be overridden by integrations. Kept for backwards compatibility. """ + if ( + self._attr_forecast is not None + and type(self).async_forecast_daily is WeatherEntity.async_forecast_daily + and type(self).async_forecast_hourly is WeatherEntity.async_forecast_hourly + and type(self).async_forecast_twice_daily + is WeatherEntity.async_forecast_twice_daily + and not self.__weather_reported_legacy_forecast + ): + self._report_legacy_forecast(self.hass) return self._attr_forecast async def async_forecast_daily(self) -> list[Forecast] | None: diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 26388c217eb..f76e93c66c3 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -100,9 +100,13 @@ } }, "issues": { - "deprecated_weather_forecast": { - "title": "The {platform} integration is using deprecated forecast", - "description": "The integration `{platform}` is using the deprecated forecast attribute.\n\nPlease {report_issue}." + "deprecated_weather_forecast_url": { + "title": "The {platform} custom integration is using deprecated weather forecast", + "description": "The custom integration `{platform}` implements the `forecast` property or sets `self._attr_forecast` in a subclass of WeatherEntity.\n\nPlease create a bug report at {issue_tracker}.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." + }, + "deprecated_weather_forecast_no_url": { + "title": "[%key:component::weather::issues::deprecated_weather_forecast_url::title%]", + "description": "The custom integration `{platform}` implements the `forecast` property or sets `self._attr_forecast` in a subclass of WeatherEntity.\n\nPlease report it to the author of the {platform} integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index cd648fda360..bc5d38e99e5 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -21,7 +21,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, DEGREE, LIGHT_LUX, PERCENTAGE, @@ -80,11 +79,10 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( WeatherFlowSensorEntityDescription( key="air_density", translation_key="air_density", - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement="kg/m³", state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=3, - raw_data_conv_fn=lambda raw_data: raw_data.m * 1000000, + suggested_display_precision=5, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), WeatherFlowSensorEntityDescription( key="air_temperature", diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py index fb41ffc1084..15ad5fa2ffb 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -23,7 +23,7 @@ from .const import ( ) from .coordinator import WeatherKitDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.WEATHER] +PLATFORMS: list[Platform] = [Platform.WEATHER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/weatherkit/const.py b/homeassistant/components/weatherkit/const.py index 590ca65c9a9..e35dd33c561 100644 --- a/homeassistant/components/weatherkit/const.py +++ b/homeassistant/components/weatherkit/const.py @@ -10,7 +10,13 @@ ATTRIBUTION = ( "https://developer.apple.com/weatherkit/data-source-attribution/" ) +MANUFACTURER = "Apple Weather" + CONF_KEY_ID = "key_id" CONF_SERVICE_ID = "service_id" CONF_TEAM_ID = "team_id" CONF_KEY_PEM = "key_pem" + +ATTR_CURRENT_WEATHER = "currentWeather" +ATTR_FORECAST_HOURLY = "forecastHourly" +ATTR_FORECAST_DAILY = "forecastDaily" diff --git a/homeassistant/components/weatherkit/entity.py b/homeassistant/components/weatherkit/entity.py new file mode 100644 index 00000000000..a244c9c4525 --- /dev/null +++ b/homeassistant/components/weatherkit/entity.py @@ -0,0 +1,33 @@ +"""Base entity for weatherkit.""" + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import WeatherKitDataUpdateCoordinator + + +class WeatherKitEntity(Entity): + """Base entity for all WeatherKit platforms.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: WeatherKitDataUpdateCoordinator, unique_id_suffix: str | None + ) -> None: + """Initialize the entity with device info and unique ID.""" + config_data = coordinator.config_entry.data + + config_entry_unique_id = ( + f"{config_data[CONF_LATITUDE]}-{config_data[CONF_LONGITUDE]}" + ) + self._attr_unique_id = config_entry_unique_id + if unique_id_suffix is not None: + self._attr_unique_id += f"_{unique_id_suffix}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry_unique_id)}, + manufacturer=MANUFACTURER, + ) diff --git a/homeassistant/components/weatherkit/sensor.py b/homeassistant/components/weatherkit/sensor.py new file mode 100644 index 00000000000..38b4a60cba5 --- /dev/null +++ b/homeassistant/components/weatherkit/sensor.py @@ -0,0 +1,73 @@ +"""WeatherKit sensors.""" + + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfVolumetricFlux +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTR_CURRENT_WEATHER, DOMAIN +from .coordinator import WeatherKitDataUpdateCoordinator +from .entity import WeatherKitEntity + +SENSORS = ( + SensorEntityDescription( + key="precipitationIntensity", + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key="pressureTrend", + device_class=SensorDeviceClass.ENUM, + icon="mdi:gauge", + options=["rising", "falling", "steady"], + translation_key="pressure_trend", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add sensor entities from a config_entry.""" + coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities( + WeatherKitSensor(coordinator, description) for description in SENSORS + ) + + +class WeatherKitSensor( + CoordinatorEntity[WeatherKitDataUpdateCoordinator], WeatherKitEntity, SensorEntity +): + """WeatherKit sensor entity.""" + + def __init__( + self, + coordinator: WeatherKitDataUpdateCoordinator, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + WeatherKitEntity.__init__( + self, coordinator, unique_id_suffix=entity_description.key + ) + self.entity_description = entity_description + + @property + def native_value(self) -> StateType: + """Return native value from coordinator current weather.""" + return self.coordinator.data[ATTR_CURRENT_WEATHER][self.entity_description.key] diff --git a/homeassistant/components/weatherkit/strings.json b/homeassistant/components/weatherkit/strings.json index 4581028f209..a0b62a5e16f 100644 --- a/homeassistant/components/weatherkit/strings.json +++ b/homeassistant/components/weatherkit/strings.json @@ -21,5 +21,17 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "pressure_trend": { + "name": "Pressure trend", + "state": { + "steady": "Steady", + "rising": "Rising", + "falling": "Falling" + } + } + } } } diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index ce997fa500f..98816d520ba 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -23,19 +23,23 @@ from homeassistant.components.weather import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, UnitOfLength, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTRIBUTION, DOMAIN +from .const import ( + ATTR_CURRENT_WEATHER, + ATTR_FORECAST_DAILY, + ATTR_FORECAST_HOURLY, + ATTRIBUTION, + DOMAIN, +) from .coordinator import WeatherKitDataUpdateCoordinator +from .entity import WeatherKitEntity async def async_setup_entry( @@ -121,13 +125,12 @@ def _map_hourly_forecast(forecast: dict[str, Any]) -> Forecast: class WeatherKitWeather( - SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator] + SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator], WeatherKitEntity ): """Weather entity for Apple WeatherKit integration.""" _attr_attribution = ATTRIBUTION - _attr_has_entity_name = True _attr_name = None _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -140,17 +143,9 @@ class WeatherKitWeather( self, coordinator: WeatherKitDataUpdateCoordinator, ) -> None: - """Initialise the platform with a data instance and site.""" + """Initialize the platform with a coordinator.""" super().__init__(coordinator) - config_data = coordinator.config_entry.data - self._attr_unique_id = ( - f"{config_data[CONF_LATITUDE]}-{config_data[CONF_LONGITUDE]}" - ) - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer="Apple Weather", - ) + WeatherKitEntity.__init__(self, coordinator, unique_id_suffix=None) @property def supported_features(self) -> WeatherEntityFeature: @@ -174,7 +169,7 @@ class WeatherKitWeather( @property def current_weather(self) -> dict[str, Any]: """Return current weather data.""" - return self.data["currentWeather"] + return self.data[ATTR_CURRENT_WEATHER] @property def condition(self) -> str | None: @@ -245,7 +240,7 @@ class WeatherKitWeather( @callback def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast.""" - daily_forecast = self.data.get("forecastDaily") + daily_forecast = self.data.get(ATTR_FORECAST_DAILY) if not daily_forecast: return None @@ -255,7 +250,7 @@ class WeatherKitWeather( @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast.""" - hourly_forecast = self.data.get("forecastHourly") + hourly_forecast = self.data.get(ATTR_FORECAST_HOURLY) if not hourly_forecast: return None diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index cef9e7bb706..2dfa48c28fe 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.auth.permissions.events import SUBSCRIBE_ALLOWLIST from homeassistant.const import ( EVENT_STATE_CHANGED, MATCH_ALL, @@ -56,6 +57,8 @@ from .messages import construct_event_message, construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" +_LOGGER = logging.getLogger(__name__) + @callback def async_register_commands( @@ -128,14 +131,15 @@ def handle_subscribe_events( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle subscribe events command.""" - # Circular dep - # pylint: disable-next=import-outside-toplevel - from .permissions import SUBSCRIBE_ALLOWLIST - event_type = msg["event_type"] if event_type not in SUBSCRIBE_ALLOWLIST and not connection.user.is_admin: - raise Unauthorized + _LOGGER.error( + "Refusing to allow %s to subscribe to event %s", + connection.user.name, + event_type, + ) + raise Unauthorized(user_id=connection.user.id) if event_type == EVENT_STATE_CHANGED: forward_events = callback( diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 6e88c36c328..e1b038f4222 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -32,9 +32,6 @@ MINIMAL_MESSAGE_SCHEMA: Final = vol.Schema( # Base schema to extend by message handlers BASE_COMMAND_MESSAGE_SCHEMA: Final = vol.Schema({vol.Required("id"): cv.positive_int}) -IDEN_TEMPLATE: Final = "__IDEN__" -IDEN_JSON_TEMPLATE: Final = '"__IDEN__"' - STATE_DIFF_ADDITIONS = "+" STATE_DIFF_REMOVALS = "-" @@ -42,6 +39,21 @@ ENTITY_EVENT_ADD = "a" ENTITY_EVENT_REMOVE = "r" ENTITY_EVENT_CHANGE = "c" +BASE_ERROR_MESSAGE = { + "type": const.TYPE_RESULT, + "success": False, +} + +INVALID_JSON_PARTIAL_MESSAGE = JSON_DUMP( + { + **BASE_ERROR_MESSAGE, + "error": { + "code": const.ERR_UNKNOWN_ERROR, + "message": "Invalid JSON in response", + }, + } +) + def result_message(iden: int, result: Any = None) -> dict[str, Any]: """Return a success result message.""" @@ -50,24 +62,21 @@ def result_message(iden: int, result: Any = None) -> dict[str, Any]: def construct_result_message(iden: int, payload: str) -> str: """Construct a success result message JSON.""" - iden_str = str(iden) - return f'{{"id":{iden_str},"type":"result","success":true,"result":{payload}}}' + return f'{{"id":{iden},"type":"result","success":true,"result":{payload}}}' def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: """Return an error result message.""" return { "id": iden, - "type": const.TYPE_RESULT, - "success": False, + **BASE_ERROR_MESSAGE, "error": {"code": code, "message": message}, } def construct_event_message(iden: int, payload: str) -> str: """Construct an event message JSON.""" - iden_str = str(iden) - return f'{{"id":{iden_str},"type":"event","event":{payload}}}' + return f'{{"id":{iden},"type":"event","event":{payload}}}' def event_message(iden: int, event: Any) -> dict[str, Any]: @@ -84,18 +93,19 @@ def cached_event_message(iden: int, event: Event) -> str: all getting many of the same events (mostly state changed) we can avoid serializing the same data for each connection. """ - return _cached_event_message(event).replace(IDEN_JSON_TEMPLATE, str(iden), 1) + return f'{_partial_cached_event_message(event)[:-1]},"id":{iden}}}' @lru_cache(maxsize=128) -def _cached_event_message(event: Event) -> str: +def _partial_cached_event_message(event: Event) -> str: """Cache and serialize the event to json. - The IDEN_TEMPLATE is used which will be replaced - with the actual iden in cached_event_message + The message is constructed without the id which appended + in cached_event_message. """ - return message_to_json( - {"id": IDEN_TEMPLATE, "type": "event", "event": event.as_dict()} + return ( + _message_to_json_or_none({"type": "event", "event": event.as_dict()}) + or INVALID_JSON_PARTIAL_MESSAGE ) @@ -108,18 +118,19 @@ def cached_state_diff_message(iden: int, event: Event) -> str: all getting many of the same events (mostly state changed) we can avoid serializing the same data for each connection. """ - return _cached_state_diff_message(event).replace(IDEN_JSON_TEMPLATE, str(iden), 1) + return f'{_partial_cached_state_diff_message(event)[:-1]},"id":{iden}}}' @lru_cache(maxsize=128) -def _cached_state_diff_message(event: Event) -> str: +def _partial_cached_state_diff_message(event: Event) -> str: """Cache and serialize the event to json. - The IDEN_TEMPLATE is used which will be replaced - with the actual iden in cached_event_message + The message is constructed without the id which + will be appended in cached_state_diff_message """ - return message_to_json( - {"id": IDEN_TEMPLATE, "type": "event", "event": _state_diff_event(event)} + return ( + _message_to_json_or_none({"type": "event", "event": _state_diff_event(event)}) + or INVALID_JSON_PARTIAL_MESSAGE ) @@ -189,8 +200,8 @@ def _state_diff( return {ENTITY_EVENT_CHANGE: {new_state.entity_id: diff}} -def message_to_json(message: dict[str, Any]) -> str: - """Serialize a websocket message to json.""" +def _message_to_json_or_none(message: dict[str, Any]) -> str | None: + """Serialize a websocket message to json or return None.""" try: return JSON_DUMP(message) except (ValueError, TypeError): @@ -200,8 +211,13 @@ def message_to_json(message: dict[str, Any]) -> str: find_paths_unserializable_data(message, dump=JSON_DUMP) ), ) - return JSON_DUMP( - error_message( - message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" - ) + return None + + +def message_to_json(message: dict[str, Any]) -> str: + """Serialize a websocket message to json or return an error.""" + return _message_to_json_or_none(message) or JSON_DUMP( + error_message( + message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" ) + ) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index a58169aa6e5..3f7cbe4cf45 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency from .const import DOMAIN from .models import WemoConfigEntryData, WemoData, async_wemo_data @@ -217,7 +217,7 @@ class WemoDispatcher: """Consider a platform as loaded and dispatch any backlog of discovered devices.""" self._dispatch_callbacks[platform] = dispatch - await gather_with_concurrency( + await gather_with_limited_concurrency( MAX_CONCURRENCY, *( dispatch(coordinator) @@ -289,7 +289,7 @@ class WemoDiscovery: if not self._static_config: return _LOGGER.debug("Adding statically configured WeMo devices") - for device in await gather_with_concurrency( + for device in await gather_with_limited_concurrency( MAX_CONCURRENCY, *( self._hass.async_add_executor_job(validate_static_config, host, port) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 225ff5603c4..496aba290ba 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -7,13 +7,16 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable import contextlib +from dataclasses import dataclass, field from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any -from aiohttp.hdrs import METH_HEAD, METH_POST +from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response +from aiowithings import NotificationCategory, WithingsClient +from aiowithings.util import to_enum import voluptuous as vol -from withings_api.common import NotifyAppli +from yarl import URL from homeassistant.components import cloud from homeassistant.components.application_credentials import ( @@ -29,6 +32,7 @@ from homeassistant.components.webhook import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN, @@ -37,16 +41,28 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .api import ConfigEntryWithingsApi from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER -from .coordinator import WithingsDataUpdateCoordinator +from .coordinator import ( + WithingsActivityDataUpdateCoordinator, + WithingsBedPresenceDataUpdateCoordinator, + WithingsDataUpdateCoordinator, + WithingsGoalsDataUpdateCoordinator, + WithingsMeasurementDataUpdateCoordinator, + WithingsSleepDataUpdateCoordinator, + WithingsWorkoutDataUpdateCoordinator, +) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { @@ -109,6 +125,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +@dataclass(slots=True) +class WithingsData: + """Dataclass to hold withings domain data.""" + + client: WithingsClient + measurement_coordinator: WithingsMeasurementDataUpdateCoordinator + sleep_coordinator: WithingsSleepDataUpdateCoordinator + bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator + goals_coordinator: WithingsGoalsDataUpdateCoordinator + activity_coordinator: WithingsActivityDataUpdateCoordinator + workout_coordinator: WithingsWorkoutDataUpdateCoordinator + coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) + + def __post_init__(self) -> None: + """Collect all coordinators in a set.""" + self.coordinators = { + self.measurement_coordinator, + self.sleep_coordinator, + self.bed_presence_coordinator, + self.goals_coordinator, + self.activity_coordinator, + self.workout_coordinator, + } + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None: @@ -120,19 +161,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry( entry, data=new_data, unique_id=unique_id ) + session = async_get_clientsession(hass) + client = WithingsClient(session=session) + implementation = await async_get_config_entry_implementation(hass, entry) + oauth_session = OAuth2Session(hass, entry, implementation) - client = ConfigEntryWithingsApi( - hass=hass, - config_entry=entry, - implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ), + async def _refresh_token() -> str: + await oauth_session.async_ensure_token_valid() + token = oauth_session.token[CONF_ACCESS_TOKEN] + if TYPE_CHECKING: + assert isinstance(token, str) + return token + + client.refresh_token_function = _refresh_token + withings_data = WithingsData( + client=client, + measurement_coordinator=WithingsMeasurementDataUpdateCoordinator(hass, client), + sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), + bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), + goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), + activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), + workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client), ) - coordinator = WithingsDataUpdateCoordinator(hass, client) - await coordinator.async_config_entry_first_refresh() + for coordinator in withings_data.coordinators: + await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = withings_data async def unregister_webhook( _: Any, @@ -140,7 +195,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await async_unsubscribe_webhooks(client) - coordinator.webhook_subscription_listener(False) + for coordinator in withings_data.coordinators: + coordinator.webhook_subscription_listener(False) async def register_webhook( _: Any, @@ -149,8 +205,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: webhook_url = await _async_cloudhook_generate_url(hass, entry) else: webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) - - if not webhook_url.startswith("https://"): + url = URL(webhook_url) + if url.scheme != "https" or url.port != 443: LOGGER.warning( "Webhook not registered - " "https and port 443 is required to register the webhook" @@ -166,11 +222,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DOMAIN, webhook_name, entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(coordinator), + get_webhook_handler(withings_data), + allowed_methods=[METH_POST], ) await async_subscribe_webhooks(client, webhook_url) - coordinator.webhook_subscription_listener(True) + for coordinator in withings_data.coordinators: + coordinator.webhook_subscription_listener(True) LOGGER.debug("Register Withings webhook: %s", webhook_url) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) @@ -207,19 +265,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_subscribe_webhooks( - client: ConfigEntryWithingsApi, webhook_url: str -) -> None: +async def async_subscribe_webhooks(client: WithingsClient, webhook_url: str) -> None: """Subscribe to Withings webhooks.""" await async_unsubscribe_webhooks(client) notification_to_subscribe = { - NotifyAppli.WEIGHT, - NotifyAppli.CIRCULATORY, - NotifyAppli.ACTIVITY, - NotifyAppli.SLEEP, - NotifyAppli.BED_IN, - NotifyAppli.BED_OUT, + NotificationCategory.WEIGHT, + NotificationCategory.PRESSURE, + NotificationCategory.ACTIVITY, + NotificationCategory.SLEEP, + NotificationCategory.IN_BED, + NotificationCategory.OUT_BED, } for notification in notification_to_subscribe: @@ -232,25 +288,26 @@ async def async_subscribe_webhooks( # Withings will HTTP HEAD the callback_url and needs some downtime # between each call or there is a higher chance of failure. await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) - await client.async_notify_subscribe(webhook_url, notification) + await client.subscribe_notification(webhook_url, notification) -async def async_unsubscribe_webhooks(client: ConfigEntryWithingsApi) -> None: +async def async_unsubscribe_webhooks(client: WithingsClient) -> None: """Unsubscribe to all Withings webhooks.""" - current_webhooks = await client.async_notify_list() + current_webhooks = await client.list_notification_configurations() - for webhook_configuration in current_webhooks.profiles: + for webhook_configuration in current_webhooks: LOGGER.debug( "Unsubscribing %s for %s in %s seconds", - webhook_configuration.callbackurl, - webhook_configuration.appli, + webhook_configuration.callback_url, + webhook_configuration.notification_category, UNSUBSCRIBE_DELAY.total_seconds(), ) # Quick calls to Withings can result in the service returning errors. # Give them some time to cool down. await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds()) - await client.async_notify_revoke( - webhook_configuration.callbackurl, webhook_configuration.appli + await client.revoke_notification_configurations( + webhook_configuration.callback_url, + webhook_configuration.notification_category, ) @@ -287,21 +344,13 @@ def json_message_response(message: str, message_code: int) -> Response: def get_webhook_handler( - coordinator: WithingsDataUpdateCoordinator, + withings_data: WithingsData, ) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: """Return webhook handler.""" async def async_webhook_handler( hass: HomeAssistant, webhook_id: str, request: Request ) -> Response | None: - # Handle http head calls to the path. - # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. - if request.method == METH_HEAD: - return Response() - - if request.method != METH_POST: - return json_message_response("Invalid method", message_code=2) - # Handle http post calls to the path. if not request.body_exists: return json_message_response("No request body", message_code=12) @@ -313,12 +362,15 @@ def get_webhook_handler( "Parameter appli not provided", message_code=20 ) - try: - appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] - except ValueError: - return json_message_response("Invalid appli provided", message_code=21) + notification_category = to_enum( + NotificationCategory, + int(params.getone("appli")), # type: ignore[arg-type] + NotificationCategory.UNKNOWN, + ) - await coordinator.async_webhook_data_updated(appli) + for coordinator in withings_data.coordinators: + if notification_category in coordinator.notification_categories: + await coordinator.async_webhook_data_updated(notification_category) return json_message_response("Success", message_code=0) diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py deleted file mode 100644 index f9739d3fb6f..00000000000 --- a/homeassistant/components/withings/api.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Api for Withings.""" -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable, Iterable -from typing import Any - -import arrow -import requests -from withings_api import AbstractWithingsApi, DateType -from withings_api.common import ( - GetSleepSummaryField, - MeasureGetMeasGroupCategory, - MeasureGetMeasResponse, - MeasureType, - NotifyAppli, - NotifyListResponse, - SleepGetSummaryResponse, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import ( - AbstractOAuth2Implementation, - OAuth2Session, -) - -from .const import LOGGER - -_RETRY_COEFFICIENT = 0.5 - - -class ConfigEntryWithingsApi(AbstractWithingsApi): - """Withing API that uses HA resources.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: AbstractOAuth2Implementation, - ) -> None: - """Initialize object.""" - self._hass = hass - self.config_entry = config_entry - self._implementation = implementation - self.session = OAuth2Session(hass, config_entry, implementation) - - def _request( - self, path: str, params: dict[str, Any], method: str = "GET" - ) -> dict[str, Any]: - """Perform an async request.""" - asyncio.run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self._hass.loop - ).result() - - access_token = self.config_entry.data["token"]["access_token"] - response = requests.request( - method, - f"{self.URL}/{path}", - params=params, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, - ) - return response.json() - - async def _do_retry(self, func: Callable[[], Awaitable[Any]], attempts=3) -> Any: - """Retry a function call. - - Withings' API occasionally and incorrectly throws errors. - Retrying the call tends to work. - """ - exception = None - for attempt in range(1, attempts + 1): - LOGGER.debug("Attempt %s of %s", attempt, attempts) - try: - return await func() - except Exception as exception1: # pylint: disable=broad-except - LOGGER.debug( - "Failed attempt %s of %s (%s)", attempt, attempts, exception1 - ) - # Make each backoff pause a little bit longer - await asyncio.sleep(_RETRY_COEFFICIENT * attempt) - exception = exception1 - continue - - if exception: - raise exception - - async def async_measure_get_meas( - self, - meastype: MeasureType | None = None, - category: MeasureGetMeasGroupCategory | None = None, - startdate: DateType | None = arrow.utcnow(), - enddate: DateType | None = arrow.utcnow(), - offset: int | None = None, - lastupdate: DateType | None = arrow.utcnow(), - ) -> MeasureGetMeasResponse: - """Get measurements.""" - - async def call_super() -> MeasureGetMeasResponse: - return await self._hass.async_add_executor_job( - self.measure_get_meas, - meastype, - category, - startdate, - enddate, - offset, - lastupdate, - ) - - return await self._do_retry(call_super) - - async def async_sleep_get_summary( - self, - data_fields: Iterable[GetSleepSummaryField], - startdateymd: DateType | None = arrow.utcnow(), - enddateymd: DateType | None = arrow.utcnow(), - offset: int | None = None, - lastupdate: DateType | None = arrow.utcnow(), - ) -> SleepGetSummaryResponse: - """Get sleep data.""" - - async def call_super() -> SleepGetSummaryResponse: - return await self._hass.async_add_executor_job( - self.sleep_get_summary, - data_fields, - startdateymd, - enddateymd, - offset, - lastupdate, - ) - - return await self._do_retry(call_super) - - async def async_notify_list( - self, appli: NotifyAppli | None = None - ) -> NotifyListResponse: - """List webhooks.""" - - async def call_super() -> NotifyListResponse: - return await self._hass.async_add_executor_job(self.notify_list, appli) - - return await self._do_retry(call_super) - - async def async_notify_subscribe( - self, - callbackurl: str, - appli: NotifyAppli | None = None, - comment: str | None = None, - ) -> None: - """Subscribe to webhook.""" - - async def call_super() -> None: - await self._hass.async_add_executor_job( - self.notify_subscribe, callbackurl, appli, comment - ) - - await self._do_retry(call_super) - - async def async_notify_revoke( - self, callbackurl: str | None = None, appli: NotifyAppli | None = None - ) -> None: - """Revoke webhook.""" - - async def call_super() -> None: - await self._hass.async_add_executor_job( - self.notify_revoke, callbackurl, appli - ) - - await self._do_retry(call_super) diff --git a/homeassistant/components/withings/application_credentials.py b/homeassistant/components/withings/application_credentials.py index 1d5b52466c4..ce96ed782dd 100644 --- a/homeassistant/components/withings/application_credentials.py +++ b/homeassistant/components/withings/application_credentials.py @@ -2,7 +2,7 @@ from typing import Any -from withings_api import AbstractWithingsApi, WithingsAuth +from aiowithings import AUTHORIZATION_URL, TOKEN_URL from homeassistant.components.application_credentials import ( AuthImplementation, @@ -24,8 +24,8 @@ async def async_get_auth_implementation( DOMAIN, credential, authorization_server=AuthorizationServer( - authorize_url=f"{WithingsAuth.URL}/oauth2_user/authorize2", - token_url=f"{AbstractWithingsApi.URL}/v2/oauth2", + authorize_url=AUTHORIZATION_URL, + token_url=TOKEN_URL, ), ) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 309ef45623f..1317befcf3f 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -1,42 +1,21 @@ """Sensors flow for Withings.""" from __future__ import annotations -from dataclasses import dataclass - -from withings_api.common import NotifyAppli +from collections.abc import Callable from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, - BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er -from .const import DOMAIN, Measurement -from .coordinator import WithingsDataUpdateCoordinator -from .entity import WithingsEntity, WithingsEntityDescription - - -@dataclass -class WithingsBinarySensorEntityDescription( - BinarySensorEntityDescription, WithingsEntityDescription -): - """Immutable class for describing withings binary sensor data.""" - - -BINARY_SENSORS = [ - # Webhook measurements. - WithingsBinarySensorEntityDescription( - key=Measurement.IN_BED.value, - measurement=Measurement.IN_BED, - measure_type=NotifyAppli.BED_IN, - translation_key="in_bed", - icon="mdi:bed", - device_class=BinarySensorDeviceClass.OCCUPANCY, - ), -] +from .const import DOMAIN +from .coordinator import WithingsBedPresenceDataUpdateCoordinator +from .entity import WithingsEntity async def async_setup_entry( @@ -45,19 +24,37 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = hass.data[DOMAIN][entry.entry_id].bed_presence_coordinator - entities = [ - WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS - ] + ent_reg = er.async_get(hass) - async_add_entities(entities) + callback: Callable[[], None] | None = None + + def _async_add_bed_presence_entity() -> None: + """Add bed presence entity.""" + async_add_entities([WithingsBinarySensor(coordinator)]) + if callback: + callback() + + if ent_reg.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"withings_{entry.unique_id}_in_bed" + ): + _async_add_bed_presence_entity() + else: + callback = coordinator.async_add_listener(_async_add_bed_presence_entity) class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): """Implementation of a Withings sensor.""" - entity_description: WithingsBinarySensorEntityDescription + _attr_icon = "mdi:bed" + _attr_translation_key = "in_bed" + _attr_device_class = BinarySensorDeviceClass.OCCUPANCY + coordinator: WithingsBedPresenceDataUpdateCoordinator + + def __init__(self, coordinator: WithingsBedPresenceDataUpdateCoordinator) -> None: + """Initialize binary sensor.""" + super().__init__(coordinator, "in_bed") @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py new file mode 100644 index 00000000000..19572682d1a --- /dev/null +++ b/homeassistant/components/withings/calendar.py @@ -0,0 +1,104 @@ +"""Calendar platform for Withings.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime + +from aiowithings import WithingsClient, WorkoutCategory + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er + +from . import DOMAIN, WithingsData +from .coordinator import WithingsWorkoutDataUpdateCoordinator +from .entity import WithingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the calendar platform for entity.""" + ent_reg = er.async_get(hass) + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + + workout_coordinator = withings_data.workout_coordinator + + calendar_setup_before = ent_reg.async_get_entity_id( + Platform.CALENDAR, + DOMAIN, + f"withings_{entry.unique_id}_workout", + ) + + if workout_coordinator.data is not None or calendar_setup_before: + async_add_entities( + [WithingsWorkoutCalendarEntity(withings_data.client, workout_coordinator)], + ) + else: + remove_calendar_listener: Callable[[], None] + + def _async_add_calendar_entity() -> None: + """Add calendar entity.""" + if workout_coordinator.data is not None: + async_add_entities( + [ + WithingsWorkoutCalendarEntity( + withings_data.client, workout_coordinator + ) + ], + ) + remove_calendar_listener() + + remove_calendar_listener = workout_coordinator.async_add_listener( + _async_add_calendar_entity + ) + + +def get_event_name(category: WorkoutCategory) -> str: + """Return human-readable category.""" + name = category.name.lower().capitalize() + return name.replace("_", " ") + + +class WithingsWorkoutCalendarEntity( + CalendarEntity, WithingsEntity[WithingsWorkoutDataUpdateCoordinator] +): + """A calendar entity.""" + + _attr_translation_key = "workout" + + def __init__( + self, client: WithingsClient, coordinator: WithingsWorkoutDataUpdateCoordinator + ) -> None: + """Create the Calendar entity.""" + super().__init__(coordinator, "workout") + self.client = client + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + workouts = await self.client.get_workouts_in_period( + start_date.date(), end_date.date() + ) + event_list = [] + for workout in workouts: + event = CalendarEvent( + start=workout.start_date, + end=workout.end_date, + summary=get_event_name(workout.category), + ) + + event_list.append(event) + + return event_list diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 8cab297b96a..31c40bf9791 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,7 +5,7 @@ from collections.abc import Mapping import logging from typing import Any -from withings_api.common import AuthScope +from aiowithings import AuthScope from homeassistant.components.webhook import async_generate_id from homeassistant.config_entries import ConfigEntry @@ -36,10 +36,10 @@ class WithingsFlowHandler( return { "scope": ",".join( [ - AuthScope.USER_INFO.value, - AuthScope.USER_METRICS.value, - AuthScope.USER_ACTIVITY.value, - AuthScope.USER_SLEEP_EVENTS.value, + AuthScope.USER_INFO, + AuthScope.USER_METRICS, + AuthScope.USER_ACTIVITY, + AuthScope.USER_SLEEP_EVENTS, ] ) } diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 545c7bfcb26..a4a34375459 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,60 +1,13 @@ """Constants used by the Withings component.""" -from enum import StrEnum import logging +LOGGER = logging.getLogger(__package__) + DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" -DATA_MANAGER = "data_manager" - -CONFIG = "config" DOMAIN = "withings" -LOG_NAMESPACE = "homeassistant.components.withings" -PROFILE = "profile" -PUSH_HANDLER = "push_handler" - -LOGGER = logging.getLogger(__package__) - - -class Measurement(StrEnum): - """Measurement supported by the withings integration.""" - - BODY_TEMP_C = "body_temperature_c" - BONE_MASS_KG = "bone_mass_kg" - DIASTOLIC_MMHG = "diastolic_blood_pressure_mmhg" - FAT_FREE_MASS_KG = "fat_free_mass_kg" - FAT_MASS_KG = "fat_mass_kg" - FAT_RATIO_PCT = "fat_ratio_pct" - HEART_PULSE_BPM = "heart_pulse_bpm" - HEIGHT_M = "height_m" - HYDRATION = "hydration" - IN_BED = "in_bed" - MUSCLE_MASS_KG = "muscle_mass_kg" - PWV = "pulse_wave_velocity" - SKIN_TEMP_C = "skin_temperature_c" - SLEEP_BREATHING_DISTURBANCES_INTENSITY = "sleep_breathing_disturbances_intensity" - SLEEP_DEEP_DURATION_SECONDS = "sleep_deep_duration_seconds" - SLEEP_HEART_RATE_AVERAGE = "sleep_heart_rate_average_bpm" - SLEEP_HEART_RATE_MAX = "sleep_heart_rate_max_bpm" - SLEEP_HEART_RATE_MIN = "sleep_heart_rate_min_bpm" - SLEEP_LIGHT_DURATION_SECONDS = "sleep_light_duration_seconds" - SLEEP_REM_DURATION_SECONDS = "sleep_rem_duration_seconds" - SLEEP_RESPIRATORY_RATE_AVERAGE = "sleep_respiratory_average_bpm" - SLEEP_RESPIRATORY_RATE_MAX = "sleep_respiratory_max_bpm" - SLEEP_RESPIRATORY_RATE_MIN = "sleep_respiratory_min_bpm" - SLEEP_SCORE = "sleep_score" - SLEEP_SNORING = "sleep_snoring" - SLEEP_SNORING_EPISODE_COUNT = "sleep_snoring_eposode_count" - SLEEP_TOSLEEP_DURATION_SECONDS = "sleep_tosleep_duration_seconds" - SLEEP_TOWAKEUP_DURATION_SECONDS = "sleep_towakeup_duration_seconds" - SLEEP_WAKEUP_COUNT = "sleep_wakeup_count" - SLEEP_WAKEUP_DURATION_SECONDS = "sleep_wakeup_duration_seconds" - SPO2_PCT = "spo2_pct" - SYSTOLIC_MMGH = "systolic_blood_pressure_mmhg" - TEMP_C = "temperature_c" - WEIGHT_KG = "weight_kg" - SCORE_POINTS = "points" UOM_BEATS_PER_MINUTE = "bpm" diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 2ec2804814b..35eeb6e62b6 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -1,17 +1,20 @@ """Withings coordinator.""" -from collections.abc import Callable -from datetime import timedelta -from typing import Any +from abc import abstractmethod +from datetime import date, datetime, timedelta +from typing import TypeVar -from withings_api.common import ( - AuthFailedException, - GetSleepSummaryField, - MeasureGroupAttribs, - MeasureType, - MeasureTypes, - NotifyAppli, - UnauthorizedException, - query_measure_groups, +from aiowithings import ( + Activity, + Goals, + MeasurementType, + NotificationCategory, + SleepSummary, + SleepSummaryDataFields, + WithingsAuthenticationFailedError, + WithingsClient, + WithingsUnauthorizedError, + Workout, + aggregate_measurements, ) from homeassistant.config_entries import ConfigEntry @@ -20,200 +23,241 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from .api import ConfigEntryWithingsApi -from .const import LOGGER, Measurement +from .const import LOGGER -WITHINGS_MEASURE_TYPE_MAP: dict[ - NotifyAppli | GetSleepSummaryField | MeasureType, Measurement -] = { - MeasureType.WEIGHT: Measurement.WEIGHT_KG, - MeasureType.FAT_MASS_WEIGHT: Measurement.FAT_MASS_KG, - MeasureType.FAT_FREE_MASS: Measurement.FAT_FREE_MASS_KG, - MeasureType.MUSCLE_MASS: Measurement.MUSCLE_MASS_KG, - MeasureType.BONE_MASS: Measurement.BONE_MASS_KG, - MeasureType.HEIGHT: Measurement.HEIGHT_M, - MeasureType.TEMPERATURE: Measurement.TEMP_C, - MeasureType.BODY_TEMPERATURE: Measurement.BODY_TEMP_C, - MeasureType.SKIN_TEMPERATURE: Measurement.SKIN_TEMP_C, - MeasureType.FAT_RATIO: Measurement.FAT_RATIO_PCT, - MeasureType.DIASTOLIC_BLOOD_PRESSURE: Measurement.DIASTOLIC_MMHG, - MeasureType.SYSTOLIC_BLOOD_PRESSURE: Measurement.SYSTOLIC_MMGH, - MeasureType.HEART_RATE: Measurement.HEART_PULSE_BPM, - MeasureType.SP02: Measurement.SPO2_PCT, - MeasureType.HYDRATION: Measurement.HYDRATION, - MeasureType.PULSE_WAVE_VELOCITY: Measurement.PWV, - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY: ( - Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY - ), - GetSleepSummaryField.DEEP_SLEEP_DURATION: Measurement.SLEEP_DEEP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_SLEEP: Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_WAKEUP: ( - Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS - ), - GetSleepSummaryField.HR_AVERAGE: Measurement.SLEEP_HEART_RATE_AVERAGE, - GetSleepSummaryField.HR_MAX: Measurement.SLEEP_HEART_RATE_MAX, - GetSleepSummaryField.HR_MIN: Measurement.SLEEP_HEART_RATE_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION: Measurement.SLEEP_LIGHT_DURATION_SECONDS, - GetSleepSummaryField.REM_SLEEP_DURATION: Measurement.SLEEP_REM_DURATION_SECONDS, - GetSleepSummaryField.RR_AVERAGE: Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, - GetSleepSummaryField.RR_MAX: Measurement.SLEEP_RESPIRATORY_RATE_MAX, - GetSleepSummaryField.RR_MIN: Measurement.SLEEP_RESPIRATORY_RATE_MIN, - GetSleepSummaryField.SLEEP_SCORE: Measurement.SLEEP_SCORE, - GetSleepSummaryField.SNORING: Measurement.SLEEP_SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT: Measurement.SLEEP_SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT: Measurement.SLEEP_WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION: Measurement.SLEEP_WAKEUP_DURATION_SECONDS, - NotifyAppli.BED_IN: Measurement.IN_BED, -} +_T = TypeVar("_T") UPDATE_INTERVAL = timedelta(minutes=10) -class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]): +class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): """Base coordinator.""" - in_bed: bool | None = None config_entry: ConfigEntry + _default_update_interval: timedelta | None = UPDATE_INTERVAL + _last_valid_update: datetime | None = None + webhooks_connected: bool = False - def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL) + super().__init__( + hass, LOGGER, name="Withings", update_interval=self._default_update_interval + ) self._client = client + self.notification_categories: set[NotificationCategory] = set() def webhook_subscription_listener(self, connected: bool) -> None: """Call when webhook status changed.""" + self.webhooks_connected = connected if connected: self.update_interval = None else: - self.update_interval = UPDATE_INTERVAL + self.update_interval = self._default_update_interval - async def _async_update_data(self) -> dict[Measurement, Any]: + async def async_webhook_data_updated( + self, notification_category: NotificationCategory + ) -> None: + """Update data when webhook is called.""" + LOGGER.debug("Withings webhook triggered for %s", notification_category) + await self.async_request_refresh() + + async def _async_update_data(self) -> _T: try: - measurements = await self._get_measurements() - sleep_summary = await self._get_sleep_summary() - except (UnauthorizedException, AuthFailedException) as exc: + return await self._internal_update_data() + except (WithingsUnauthorizedError, WithingsAuthenticationFailedError) as exc: raise ConfigEntryAuthFailed from exc - return { - **measurements, - **sleep_summary, + + @abstractmethod + async def _internal_update_data(self) -> _T: + """Update coordinator data.""" + + +class WithingsMeasurementDataUpdateCoordinator( + WithingsDataUpdateCoordinator[dict[MeasurementType, float]] +): + """Withings measurement coordinator.""" + + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotificationCategory.WEIGHT, + NotificationCategory.PRESSURE, } + self._previous_data: dict[MeasurementType, float] = {} - async def _get_measurements(self) -> dict[Measurement, Any]: - LOGGER.debug("Updating withings measures") - now = dt_util.utcnow() - startdate = now - timedelta(days=7) - - response = await self._client.async_measure_get_meas( - None, None, startdate, now, None, startdate - ) - - # Sort from oldest to newest. - groups = sorted( - query_measure_groups( - response, MeasureTypes.ANY, MeasureGroupAttribs.UNAMBIGUOUS - ), - key=lambda group: group.created.datetime, - reverse=False, - ) - - return { - WITHINGS_MEASURE_TYPE_MAP[measure.type]: round( - float(measure.value * pow(10, measure.unit)), 2 + async def _internal_update_data(self) -> dict[MeasurementType, float]: + """Retrieve measurement data.""" + if self._last_valid_update is None: + now = dt_util.utcnow() + startdate = now - timedelta(days=14) + measurements = await self._client.get_measurement_in_period(startdate, now) + else: + measurements = await self._client.get_measurement_since( + self._last_valid_update ) - for group in groups - for measure in group.measures - if measure.type in WITHINGS_MEASURE_TYPE_MAP + + if measurements: + self._last_valid_update = measurements[0].taken_at + aggregated_measurements = aggregate_measurements(measurements) + self._previous_data.update(aggregated_measurements) + return self._previous_data + + +class WithingsSleepDataUpdateCoordinator( + WithingsDataUpdateCoordinator[SleepSummary | None] +): + """Withings sleep coordinator.""" + + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotificationCategory.SLEEP, } - async def _get_sleep_summary(self) -> dict[Measurement, Any]: + async def _internal_update_data(self) -> SleepSummary | None: + """Retrieve sleep data.""" now = dt_util.now() yesterday = now - timedelta(days=1) yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12) yesterday_noon_utc = dt_util.as_utc(yesterday_noon) - response = await self._client.async_sleep_get_summary( - lastupdate=yesterday_noon_utc, - data_fields=[ - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, - GetSleepSummaryField.DEEP_SLEEP_DURATION, - GetSleepSummaryField.DURATION_TO_SLEEP, - GetSleepSummaryField.DURATION_TO_WAKEUP, - GetSleepSummaryField.HR_AVERAGE, - GetSleepSummaryField.HR_MAX, - GetSleepSummaryField.HR_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION, - GetSleepSummaryField.REM_SLEEP_DURATION, - GetSleepSummaryField.RR_AVERAGE, - GetSleepSummaryField.RR_MAX, - GetSleepSummaryField.RR_MIN, - GetSleepSummaryField.SLEEP_SCORE, - GetSleepSummaryField.SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION, + response = await self._client.get_sleep_summary_since( + sleep_summary_since=yesterday_noon_utc, + sleep_summary_data_fields=[ + SleepSummaryDataFields.BREATHING_DISTURBANCES_INTENSITY, + SleepSummaryDataFields.DEEP_SLEEP_DURATION, + SleepSummaryDataFields.SLEEP_LATENCY, + SleepSummaryDataFields.WAKE_UP_LATENCY, + SleepSummaryDataFields.AVERAGE_HEART_RATE, + SleepSummaryDataFields.MIN_HEART_RATE, + SleepSummaryDataFields.MAX_HEART_RATE, + SleepSummaryDataFields.LIGHT_SLEEP_DURATION, + SleepSummaryDataFields.REM_SLEEP_DURATION, + SleepSummaryDataFields.AVERAGE_RESPIRATION_RATE, + SleepSummaryDataFields.MIN_RESPIRATION_RATE, + SleepSummaryDataFields.MAX_RESPIRATION_RATE, + SleepSummaryDataFields.SLEEP_SCORE, + SleepSummaryDataFields.SNORING, + SleepSummaryDataFields.SNORING_COUNT, + SleepSummaryDataFields.WAKE_UP_COUNT, + SleepSummaryDataFields.TOTAL_TIME_AWAKE, ], ) + if not response: + return None + return response[0] - # Set the default to empty lists. - raw_values: dict[GetSleepSummaryField, list[int]] = { - field: [] for field in GetSleepSummaryField - } - # Collect the raw data. - for serie in response.series: - data = serie.data +class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]): + """Withings bed presence coordinator.""" - for field in GetSleepSummaryField: - raw_values[field].append(dict(data)[field.value]) + in_bed: bool | None = None + _default_update_interval = None - values: dict[GetSleepSummaryField, float] = {} - - def average(data: list[int]) -> float: - return sum(data) / len(data) - - def set_value(field: GetSleepSummaryField, func: Callable) -> None: - non_nones = [ - value for value in raw_values.get(field, []) if value is not None - ] - values[field] = func(non_nones) if non_nones else None - - set_value(GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, average) - set_value(GetSleepSummaryField.DEEP_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.DURATION_TO_SLEEP, average) - set_value(GetSleepSummaryField.DURATION_TO_WAKEUP, average) - set_value(GetSleepSummaryField.HR_AVERAGE, average) - set_value(GetSleepSummaryField.HR_MAX, average) - set_value(GetSleepSummaryField.HR_MIN, average) - set_value(GetSleepSummaryField.LIGHT_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.REM_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.RR_AVERAGE, average) - set_value(GetSleepSummaryField.RR_MAX, average) - set_value(GetSleepSummaryField.RR_MIN, average) - set_value(GetSleepSummaryField.SLEEP_SCORE, max) - set_value(GetSleepSummaryField.SNORING, average) - set_value(GetSleepSummaryField.SNORING_EPISODE_COUNT, sum) - set_value(GetSleepSummaryField.WAKEUP_COUNT, sum) - set_value(GetSleepSummaryField.WAKEUP_DURATION, average) - - return { - WITHINGS_MEASURE_TYPE_MAP[field]: round(value, 4) - if value is not None - else None - for field, value in values.items() + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotificationCategory.IN_BED, + NotificationCategory.OUT_BED, } async def async_webhook_data_updated( - self, notification_category: NotifyAppli + self, notification_category: NotificationCategory ) -> None: - """Update data when webhook is called.""" - LOGGER.debug("Withings webhook triggered") - if notification_category in { - NotifyAppli.WEIGHT, - NotifyAppli.CIRCULATORY, - NotifyAppli.SLEEP, - }: - await self.async_request_refresh() + """Only set new in bed value instead of refresh.""" + self.in_bed = notification_category == NotificationCategory.IN_BED + self.async_update_listeners() - elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: - self.in_bed = notification_category == NotifyAppli.BED_IN - self.async_update_listeners() + async def _internal_update_data(self) -> None: + """Update coordinator data.""" + + +class WithingsGoalsDataUpdateCoordinator(WithingsDataUpdateCoordinator[Goals]): + """Withings goals coordinator.""" + + _default_update_interval = timedelta(hours=1) + + def webhook_subscription_listener(self, connected: bool) -> None: + """Call when webhook status changed.""" + # Webhooks aren't available for this datapoint, so we keep polling + + async def _internal_update_data(self) -> Goals: + """Retrieve goals data.""" + return await self._client.get_goals() + + +class WithingsActivityDataUpdateCoordinator( + WithingsDataUpdateCoordinator[Activity | None] +): + """Withings activity coordinator.""" + + _previous_data: Activity | None = None + + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotificationCategory.ACTIVITY, + } + + async def _internal_update_data(self) -> Activity | None: + """Retrieve latest activity.""" + if self._last_valid_update is None: + now = dt_util.utcnow() + startdate = now - timedelta(days=14) + activities = await self._client.get_activities_in_period( + startdate.date(), now.date() + ) + else: + activities = await self._client.get_activities_since( + self._last_valid_update + ) + + today = date.today() + for activity in activities: + if activity.date == today: + self._previous_data = activity + self._last_valid_update = activity.modified + return activity + if self._previous_data and self._previous_data.date == today: + return self._previous_data + return None + + +class WithingsWorkoutDataUpdateCoordinator( + WithingsDataUpdateCoordinator[Workout | None] +): + """Withings workout coordinator.""" + + _previous_data: Workout | None = None + + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotificationCategory.ACTIVITY, + } + + async def _internal_update_data(self) -> Workout | None: + """Retrieve latest workout.""" + if self._last_valid_update is None: + now = dt_util.utcnow() + startdate = now - timedelta(days=14) + workouts = await self._client.get_workouts_in_period( + startdate.date(), now.date() + ) + else: + workouts = await self._client.get_workouts_since(self._last_valid_update) + if not workouts: + return self._previous_data + latest_workout = max(workouts, key=lambda workout: workout.end_date) + if ( + self._previous_data is None + or self._previous_data.end_date >= latest_workout.end_date + ): + self._previous_data = latest_workout + self._last_valid_update = latest_workout.end_date + return self._previous_data diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py new file mode 100644 index 00000000000..31c9ffef569 --- /dev/null +++ b/homeassistant/components/withings/diagnostics.py @@ -0,0 +1,38 @@ +"""Diagnostics support for Withings.""" +from __future__ import annotations + +from typing import Any + +from yarl import URL + +from homeassistant.components.webhook import async_generate_url as webhook_generate_url +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant + +from . import CONF_CLOUDHOOK_URL, WithingsData +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + url = URL(webhook_url) + has_valid_external_webhook_url = url.scheme == "https" and url.port == 443 + + has_cloudhooks = CONF_CLOUDHOOK_URL in entry.data + + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + + return { + "has_valid_external_webhook_url": has_valid_external_webhook_url, + "has_cloudhooks": has_cloudhooks, + "webhooks_connected": withings_data.measurement_coordinator.webhooks_connected, + "received_measurements": list(withings_data.measurement_coordinator.data), + "received_sleep_data": withings_data.sleep_coordinator.data is not None, + "received_workout_data": withings_data.workout_coordinator.data is not None, + "received_activity_data": withings_data.activity_coordinator.data is not None, + } diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index 8005f97bfaa..7f3e694533c 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -1,46 +1,30 @@ """Base entity for Withings.""" from __future__ import annotations -from dataclasses import dataclass - -from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli +from typing import TypeVar from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, Measurement +from .const import DOMAIN from .coordinator import WithingsDataUpdateCoordinator - -@dataclass -class WithingsEntityDescriptionMixin: - """Mixin for describing withings data.""" - - measurement: Measurement - measure_type: NotifyAppli | GetSleepSummaryField | MeasureType +_T = TypeVar("_T", bound=WithingsDataUpdateCoordinator) -@dataclass -class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin): - """Immutable class for describing withings data.""" - - -class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]): +class WithingsEntity(CoordinatorEntity[_T]): """Base class for withings entities.""" - entity_description: WithingsEntityDescription _attr_has_entity_name = True def __init__( self, - coordinator: WithingsDataUpdateCoordinator, - description: WithingsEntityDescription, + coordinator: _T, + key: str, ) -> None: """Initialize the Withings entity.""" super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{description.measurement.value}" + self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, manufacturer="Withings", diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index edc8aab83b7..d43ae7da50c 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -2,11 +2,12 @@ "domain": "withings", "name": "Withings", "after_dependencies": ["cloud"], - "codeowners": ["@vangorra", "@joostlek"], + "codeowners": ["@joostlek"], "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/withings", - "iot_class": "cloud_polling", - "loggers": ["withings_api"], - "requirements": ["withings-api==2.4.0"] + "iot_class": "cloud_push", + "loggers": ["aiowithings"], + "quality_scale": "platinum", + "requirements": ["aiowithings==1.0.2"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 77a706dc55d..707059a2930 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -1,9 +1,19 @@ """Sensors flow for Withings.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime +from typing import Generic, TypeVar -from withings_api.common import GetSleepSummaryField, MeasureType +from aiowithings import ( + Activity, + Goals, + MeasurementType, + SleepSummary, + Workout, + WorkoutCategory, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -14,6 +24,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + Platform, UnitOfLength, UnitOfMass, UnitOfSpeed, @@ -22,153 +33,166 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util +from . import WithingsData from .const import ( DOMAIN, + LOGGER, SCORE_POINTS, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, UOM_FREQUENCY, UOM_MMHG, - Measurement, ) -from .coordinator import WithingsDataUpdateCoordinator -from .entity import WithingsEntity, WithingsEntityDescription +from .coordinator import ( + WithingsActivityDataUpdateCoordinator, + WithingsDataUpdateCoordinator, + WithingsGoalsDataUpdateCoordinator, + WithingsMeasurementDataUpdateCoordinator, + WithingsSleepDataUpdateCoordinator, + WithingsWorkoutDataUpdateCoordinator, +) +from .entity import WithingsEntity @dataclass -class WithingsSensorEntityDescription( - SensorEntityDescription, WithingsEntityDescription +class WithingsMeasurementSensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + measurement_type: MeasurementType + + +@dataclass +class WithingsMeasurementSensorEntityDescription( + SensorEntityDescription, WithingsMeasurementSensorEntityDescriptionMixin ): - """Immutable class for describing withings binary sensor data.""" + """Immutable class for describing withings data.""" -SENSORS = [ - WithingsSensorEntityDescription( - key=Measurement.WEIGHT_KG.value, - measurement=Measurement.WEIGHT_KG, - measure_type=MeasureType.WEIGHT, +MEASUREMENT_SENSORS: dict[ + MeasurementType, WithingsMeasurementSensorEntityDescription +] = { + MeasurementType.WEIGHT: WithingsMeasurementSensorEntityDescription( + key="weight_kg", + measurement_type=MeasurementType.WEIGHT, native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.FAT_MASS_KG.value, - measurement=Measurement.FAT_MASS_KG, - measure_type=MeasureType.FAT_MASS_WEIGHT, + MeasurementType.FAT_MASS_WEIGHT: WithingsMeasurementSensorEntityDescription( + key="fat_mass_kg", + measurement_type=MeasurementType.FAT_MASS_WEIGHT, translation_key="fat_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.FAT_FREE_MASS_KG.value, - measurement=Measurement.FAT_FREE_MASS_KG, - measure_type=MeasureType.FAT_FREE_MASS, + MeasurementType.FAT_FREE_MASS: WithingsMeasurementSensorEntityDescription( + key="fat_free_mass_kg", + measurement_type=MeasurementType.FAT_FREE_MASS, translation_key="fat_free_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.MUSCLE_MASS_KG.value, - measurement=Measurement.MUSCLE_MASS_KG, - measure_type=MeasureType.MUSCLE_MASS, + MeasurementType.MUSCLE_MASS: WithingsMeasurementSensorEntityDescription( + key="muscle_mass_kg", + measurement_type=MeasurementType.MUSCLE_MASS, translation_key="muscle_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.BONE_MASS_KG.value, - measurement=Measurement.BONE_MASS_KG, - measure_type=MeasureType.BONE_MASS, + MeasurementType.BONE_MASS: WithingsMeasurementSensorEntityDescription( + key="bone_mass_kg", + measurement_type=MeasurementType.BONE_MASS, translation_key="bone_mass", + icon="mdi:bone", native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.HEIGHT_M.value, - measurement=Measurement.HEIGHT_M, - measure_type=MeasureType.HEIGHT, + MeasurementType.HEIGHT: WithingsMeasurementSensorEntityDescription( + key="height_m", + measurement_type=MeasurementType.HEIGHT, translation_key="height", native_unit_of_measurement=UnitOfLength.METERS, + suggested_display_precision=1, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.TEMP_C.value, - measurement=Measurement.TEMP_C, - measure_type=MeasureType.TEMPERATURE, + MeasurementType.TEMPERATURE: WithingsMeasurementSensorEntityDescription( + key="temperature_c", + measurement_type=MeasurementType.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.BODY_TEMP_C.value, - measurement=Measurement.BODY_TEMP_C, - measure_type=MeasureType.BODY_TEMPERATURE, + MeasurementType.BODY_TEMPERATURE: WithingsMeasurementSensorEntityDescription( + key="body_temperature_c", + measurement_type=MeasurementType.BODY_TEMPERATURE, translation_key="body_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.SKIN_TEMP_C.value, - measurement=Measurement.SKIN_TEMP_C, - measure_type=MeasureType.SKIN_TEMPERATURE, + MeasurementType.SKIN_TEMPERATURE: WithingsMeasurementSensorEntityDescription( + key="skin_temperature_c", + measurement_type=MeasurementType.SKIN_TEMPERATURE, translation_key="skin_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.FAT_RATIO_PCT.value, - measurement=Measurement.FAT_RATIO_PCT, - measure_type=MeasureType.FAT_RATIO, + MeasurementType.FAT_RATIO: WithingsMeasurementSensorEntityDescription( + key="fat_ratio_pct", + measurement_type=MeasurementType.FAT_RATIO, translation_key="fat_ratio", native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.DIASTOLIC_MMHG.value, - measurement=Measurement.DIASTOLIC_MMHG, - measure_type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, + MeasurementType.DIASTOLIC_BLOOD_PRESSURE: WithingsMeasurementSensorEntityDescription( + key="diastolic_blood_pressure_mmhg", + measurement_type=MeasurementType.DIASTOLIC_BLOOD_PRESSURE, translation_key="diastolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.SYSTOLIC_MMGH.value, - measurement=Measurement.SYSTOLIC_MMGH, - measure_type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, + MeasurementType.SYSTOLIC_BLOOD_PRESSURE: WithingsMeasurementSensorEntityDescription( + key="systolic_blood_pressure_mmhg", + measurement_type=MeasurementType.SYSTOLIC_BLOOD_PRESSURE, translation_key="systolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.HEART_PULSE_BPM.value, - measurement=Measurement.HEART_PULSE_BPM, - measure_type=MeasureType.HEART_RATE, + MeasurementType.HEART_RATE: WithingsMeasurementSensorEntityDescription( + key="heart_pulse_bpm", + measurement_type=MeasurementType.HEART_RATE, translation_key="heart_pulse", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.SPO2_PCT.value, - measurement=Measurement.SPO2_PCT, - measure_type=MeasureType.SP02, + MeasurementType.SP02: WithingsMeasurementSensorEntityDescription( + key="spo2_pct", + measurement_type=MeasurementType.SP02, translation_key="spo2", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.HYDRATION.value, - measurement=Measurement.HYDRATION, - measure_type=MeasureType.HYDRATION, + MeasurementType.HYDRATION: WithingsMeasurementSensorEntityDescription( + key="hydration", + measurement_type=MeasurementType.HYDRATION, translation_key="hydration", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, @@ -176,38 +200,83 @@ SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.PWV.value, - measurement=Measurement.PWV, - measure_type=MeasureType.PULSE_WAVE_VELOCITY, + MeasurementType.PULSE_WAVE_VELOCITY: WithingsMeasurementSensorEntityDescription( + key="pulse_wave_velocity", + measurement_type=MeasurementType.PULSE_WAVE_VELOCITY, translation_key="pulse_wave_velocity", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, - measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, - measure_type=GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, + MeasurementType.VO2: WithingsMeasurementSensorEntityDescription( + key="vo2_max", + measurement_type=MeasurementType.VO2, + translation_key="vo2_max", + native_unit_of_measurement="ml/min/kg", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + MeasurementType.EXTRACELLULAR_WATER: WithingsMeasurementSensorEntityDescription( + key="extracellular_water", + measurement_type=MeasurementType.EXTRACELLULAR_WATER, + translation_key="extracellular_water", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + MeasurementType.INTRACELLULAR_WATER: WithingsMeasurementSensorEntityDescription( + key="intracellular_water", + measurement_type=MeasurementType.INTRACELLULAR_WATER, + translation_key="intracellular_water", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + MeasurementType.VASCULAR_AGE: WithingsMeasurementSensorEntityDescription( + key="vascular_age", + measurement_type=MeasurementType.VASCULAR_AGE, + translation_key="vascular_age", + entity_registry_enabled_default=False, + ), +} + + +@dataclass +class WithingsSleepSensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[SleepSummary], StateType] + + +@dataclass +class WithingsSleepSensorEntityDescription( + SensorEntityDescription, WithingsSleepSensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +SLEEP_SENSORS = [ + WithingsSleepSensorEntityDescription( + key="sleep_breathing_disturbances_intensity", + value_fn=lambda sleep_summary: sleep_summary.breathing_disturbances_intensity, translation_key="breathing_disturbances_intensity", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_DEEP_DURATION_SECONDS, - measure_type=GetSleepSummaryField.DEEP_SLEEP_DURATION, + WithingsSleepSensorEntityDescription( + key="sleep_deep_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.deep_sleep_duration, translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, - measure_type=GetSleepSummaryField.DURATION_TO_SLEEP, + WithingsSleepSensorEntityDescription( + key="sleep_tosleep_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.sleep_latency, translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", @@ -215,10 +284,9 @@ SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, - measure_type=GetSleepSummaryField.DURATION_TO_WAKEUP, + WithingsSleepSensorEntityDescription( + key="sleep_towakeup_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.wake_up_latency, translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", @@ -226,40 +294,36 @@ SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_HEART_RATE_AVERAGE.value, - measurement=Measurement.SLEEP_HEART_RATE_AVERAGE, - measure_type=GetSleepSummaryField.HR_AVERAGE, + WithingsSleepSensorEntityDescription( + key="sleep_heart_rate_average_bpm", + value_fn=lambda sleep_summary: sleep_summary.average_heart_rate, translation_key="average_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_HEART_RATE_MAX.value, - measurement=Measurement.SLEEP_HEART_RATE_MAX, - measure_type=GetSleepSummaryField.HR_MAX, + WithingsSleepSensorEntityDescription( + key="sleep_heart_rate_max_bpm", + value_fn=lambda sleep_summary: sleep_summary.max_heart_rate, translation_key="maximum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_HEART_RATE_MIN.value, - measurement=Measurement.SLEEP_HEART_RATE_MIN, - measure_type=GetSleepSummaryField.HR_MIN, + WithingsSleepSensorEntityDescription( + key="sleep_heart_rate_min_bpm", + value_fn=lambda sleep_summary: sleep_summary.min_heart_rate, translation_key="minimum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_LIGHT_DURATION_SECONDS, - measure_type=GetSleepSummaryField.LIGHT_SLEEP_DURATION, + WithingsSleepSensorEntityDescription( + key="sleep_light_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.light_sleep_duration, translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", @@ -267,10 +331,9 @@ SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_REM_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_REM_DURATION_SECONDS, - measure_type=GetSleepSummaryField.REM_SLEEP_DURATION, + WithingsSleepSensorEntityDescription( + key="sleep_rem_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.rem_sleep_duration, translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", @@ -278,73 +341,65 @@ SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value, - measurement=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, - measure_type=GetSleepSummaryField.RR_AVERAGE, + WithingsSleepSensorEntityDescription( + key="sleep_respiratory_average_bpm", + value_fn=lambda sleep_summary: sleep_summary.average_respiration_rate, translation_key="average_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value, - measurement=Measurement.SLEEP_RESPIRATORY_RATE_MAX, - measure_type=GetSleepSummaryField.RR_MAX, + WithingsSleepSensorEntityDescription( + key="sleep_respiratory_max_bpm", + value_fn=lambda sleep_summary: sleep_summary.max_respiration_rate, translation_key="maximum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value, - measurement=Measurement.SLEEP_RESPIRATORY_RATE_MIN, - measure_type=GetSleepSummaryField.RR_MIN, + WithingsSleepSensorEntityDescription( + key="sleep_respiratory_min_bpm", + value_fn=lambda sleep_summary: sleep_summary.min_respiration_rate, translation_key="minimum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_SCORE.value, - measurement=Measurement.SLEEP_SCORE, - measure_type=GetSleepSummaryField.SLEEP_SCORE, + WithingsSleepSensorEntityDescription( + key="sleep_score", + value_fn=lambda sleep_summary: sleep_summary.sleep_score, translation_key="sleep_score", native_unit_of_measurement=SCORE_POINTS, icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_SNORING.value, - measurement=Measurement.SLEEP_SNORING, - measure_type=GetSleepSummaryField.SNORING, + WithingsSleepSensorEntityDescription( + key="sleep_snoring", + value_fn=lambda sleep_summary: sleep_summary.snoring, translation_key="snoring", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value, - measurement=Measurement.SLEEP_SNORING_EPISODE_COUNT, - measure_type=GetSleepSummaryField.SNORING_EPISODE_COUNT, + WithingsSleepSensorEntityDescription( + key="sleep_snoring_eposode_count", + value_fn=lambda sleep_summary: sleep_summary.snoring_count, translation_key="snoring_episode_count", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_WAKEUP_COUNT.value, - measurement=Measurement.SLEEP_WAKEUP_COUNT, - measure_type=GetSleepSummaryField.WAKEUP_COUNT, + WithingsSleepSensorEntityDescription( + key="sleep_wakeup_count", + value_fn=lambda sleep_summary: sleep_summary.wake_up_count, translation_key="wakeup_count", native_unit_of_measurement=UOM_FREQUENCY, icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_WAKEUP_DURATION_SECONDS, - measure_type=GetSleepSummaryField.WAKEUP_DURATION, + WithingsSleepSensorEntityDescription( + key="sleep_wakeup_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.total_time_awake, translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", @@ -355,31 +410,496 @@ SENSORS = [ ] +@dataclass +class WithingsActivitySensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[Activity], StateType] + + +@dataclass +class WithingsActivitySensorEntityDescription( + SensorEntityDescription, WithingsActivitySensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +ACTIVITY_SENSORS = [ + WithingsActivitySensorEntityDescription( + key="activity_steps_today", + value_fn=lambda activity: activity.steps, + translation_key="activity_steps_today", + icon="mdi:shoe-print", + native_unit_of_measurement="steps", + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_distance_today", + value_fn=lambda activity: activity.distance, + translation_key="activity_distance_today", + suggested_display_precision=0, + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_floors_climbed_today", + value_fn=lambda activity: activity.floors_climbed, + translation_key="activity_floors_climbed_today", + icon="mdi:stairs-up", + native_unit_of_measurement="floors", + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_soft_duration_today", + value_fn=lambda activity: activity.soft_activity, + translation_key="activity_soft_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + WithingsActivitySensorEntityDescription( + key="activity_moderate_duration_today", + value_fn=lambda activity: activity.moderate_activity, + translation_key="activity_moderate_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + WithingsActivitySensorEntityDescription( + key="activity_intense_duration_today", + value_fn=lambda activity: activity.intense_activity, + translation_key="activity_intense_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + WithingsActivitySensorEntityDescription( + key="activity_active_duration_today", + value_fn=lambda activity: activity.total_time_active, + translation_key="activity_active_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_active_calories_burnt_today", + value_fn=lambda activity: activity.active_calories_burnt, + suggested_display_precision=1, + translation_key="activity_active_calories_burnt_today", + native_unit_of_measurement="calories", + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_total_calories_burnt_today", + value_fn=lambda activity: activity.total_calories_burnt, + suggested_display_precision=1, + translation_key="activity_total_calories_burnt_today", + native_unit_of_measurement="calories", + state_class=SensorStateClass.TOTAL, + ), +] + + +STEP_GOAL = "steps" +SLEEP_GOAL = "sleep" +WEIGHT_GOAL = "weight" + + +@dataclass +class WithingsGoalsSensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[Goals], StateType] + + +@dataclass +class WithingsGoalsSensorEntityDescription( + SensorEntityDescription, WithingsGoalsSensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { + STEP_GOAL: WithingsGoalsSensorEntityDescription( + key="step_goal", + value_fn=lambda goals: goals.steps, + icon="mdi:shoe-print", + translation_key="step_goal", + native_unit_of_measurement="steps", + state_class=SensorStateClass.MEASUREMENT, + ), + SLEEP_GOAL: WithingsGoalsSensorEntityDescription( + key="sleep_goal", + value_fn=lambda goals: goals.sleep, + icon="mdi:bed-clock", + translation_key="sleep_goal", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + WEIGHT_GOAL: WithingsGoalsSensorEntityDescription( + key="weight_goal", + value_fn=lambda goals: goals.weight, + translation_key="weight_goal", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + ), +} + + +@dataclass +class WithingsWorkoutSensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[Workout], StateType] + + +@dataclass +class WithingsWorkoutSensorEntityDescription( + SensorEntityDescription, WithingsWorkoutSensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +_WORKOUT_CATEGORY = [ + workout_category.name.lower() for workout_category in WorkoutCategory +] + + +WORKOUT_SENSORS = [ + WithingsWorkoutSensorEntityDescription( + key="workout_type", + value_fn=lambda workout: workout.category.name.lower(), + device_class=SensorDeviceClass.ENUM, + translation_key="workout_type", + options=_WORKOUT_CATEGORY, + ), + WithingsWorkoutSensorEntityDescription( + key="workout_active_calories_burnt", + value_fn=lambda workout: workout.active_calories_burnt, + translation_key="workout_active_calories_burnt", + suggested_display_precision=1, + native_unit_of_measurement="calories", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_distance", + value_fn=lambda workout: workout.distance, + translation_key="workout_distance", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + suggested_display_precision=0, + icon="mdi:map-marker-distance", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_floors_climbed", + value_fn=lambda workout: workout.floors_climbed, + translation_key="workout_floors_climbed", + icon="mdi:stairs-up", + native_unit_of_measurement="floors", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_intensity", + value_fn=lambda workout: workout.intensity, + translation_key="workout_intensity", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_pause_duration", + value_fn=lambda workout: workout.pause_duration or 0, + translation_key="workout_pause_duration", + icon="mdi:timer-pause", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), + WithingsWorkoutSensorEntityDescription( + key="workout_duration", + value_fn=lambda workout: ( + workout.end_date - workout.start_date + ).total_seconds(), + translation_key="workout_duration", + icon="mdi:timer", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), +] + + +def get_current_goals(goals: Goals) -> set[str]: + """Return a list of present goals.""" + result = set() + for goal in (STEP_GOAL, SLEEP_GOAL, WEIGHT_GOAL): + if getattr(goals, goal): + result.add(goal) + return result + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + ent_reg = er.async_get(hass) - async_add_entities(WithingsSensor(coordinator, attribute) for attribute in SENSORS) + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + + measurement_coordinator = withings_data.measurement_coordinator + + entities: list[SensorEntity] = [] + entities.extend( + WithingsMeasurementSensor( + measurement_coordinator, MEASUREMENT_SENSORS[measurement_type] + ) + for measurement_type in measurement_coordinator.data + if measurement_type in MEASUREMENT_SENSORS + ) + + current_measurement_types = set(measurement_coordinator.data) + + def _async_measurement_listener() -> None: + """Listen for new measurements and add sensors if they did not exist.""" + received_measurement_types = set(measurement_coordinator.data) + new_measurement_types = received_measurement_types - current_measurement_types + if new_measurement_types: + current_measurement_types.update(new_measurement_types) + async_add_entities( + WithingsMeasurementSensor( + measurement_coordinator, MEASUREMENT_SENSORS[measurement_type] + ) + for measurement_type in new_measurement_types + ) + + measurement_coordinator.async_add_listener(_async_measurement_listener) + + goals_coordinator = withings_data.goals_coordinator + + current_goals = get_current_goals(goals_coordinator.data) + + entities.extend( + WithingsGoalsSensor(goals_coordinator, GOALS_SENSORS[goal]) + for goal in current_goals + ) + + def _async_goals_listener() -> None: + """Listen for new goals and add sensors if they did not exist.""" + received_goals = get_current_goals(goals_coordinator.data) + new_goals = received_goals - current_goals + if new_goals: + current_goals.update(new_goals) + async_add_entities( + WithingsGoalsSensor(goals_coordinator, GOALS_SENSORS[goal]) + for goal in new_goals + ) + + goals_coordinator.async_add_listener(_async_goals_listener) + + activity_coordinator = withings_data.activity_coordinator + + activity_entities_setup_before = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"withings_{entry.unique_id}_activity_steps_today" + ) + + if activity_coordinator.data is not None or activity_entities_setup_before: + entities.extend( + WithingsActivitySensor(activity_coordinator, attribute) + for attribute in ACTIVITY_SENSORS + ) + else: + remove_activity_listener: Callable[[], None] + + def _async_add_activity_entities() -> None: + """Add activity entities.""" + if activity_coordinator.data is not None: + async_add_entities( + WithingsActivitySensor(activity_coordinator, attribute) + for attribute in ACTIVITY_SENSORS + ) + remove_activity_listener() + + remove_activity_listener = activity_coordinator.async_add_listener( + _async_add_activity_entities + ) + + sleep_coordinator = withings_data.sleep_coordinator + + sleep_entities_setup_before = ent_reg.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"withings_{entry.unique_id}_sleep_deep_duration_seconds", + ) + + if sleep_coordinator.data is not None or sleep_entities_setup_before: + entities.extend( + WithingsSleepSensor(sleep_coordinator, attribute) + for attribute in SLEEP_SENSORS + ) + else: + remove_sleep_listener: Callable[[], None] + + def _async_add_sleep_entities() -> None: + """Add sleep entities.""" + if sleep_coordinator.data is not None: + async_add_entities( + WithingsSleepSensor(sleep_coordinator, attribute) + for attribute in SLEEP_SENSORS + ) + remove_sleep_listener() + + remove_sleep_listener = sleep_coordinator.async_add_listener( + _async_add_sleep_entities + ) + + workout_coordinator = withings_data.workout_coordinator + + workout_entities_setup_before = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"withings_{entry.unique_id}_workout_type" + ) + + if workout_coordinator.data is not None or workout_entities_setup_before: + entities.extend( + WithingsWorkoutSensor(workout_coordinator, attribute) + for attribute in WORKOUT_SENSORS + ) + else: + remove_workout_listener: Callable[[], None] + + def _async_add_workout_entities() -> None: + """Add workout entities.""" + if workout_coordinator.data is not None: + async_add_entities( + WithingsWorkoutSensor(workout_coordinator, attribute) + for attribute in WORKOUT_SENSORS + ) + remove_workout_listener() + + remove_workout_listener = workout_coordinator.async_add_listener( + _async_add_workout_entities + ) + + if not entities: + LOGGER.warning( + "No data found for Withings entry %s, sensors will be added when new data is available" + ) + + async_add_entities(entities) -class WithingsSensor(WithingsEntity, SensorEntity): +_T = TypeVar("_T", bound=WithingsDataUpdateCoordinator) +_ED = TypeVar("_ED", bound=SensorEntityDescription) + + +class WithingsSensor(WithingsEntity[_T], SensorEntity, Generic[_T, _ED]): """Implementation of a Withings sensor.""" - entity_description: WithingsSensorEntityDescription + entity_description: _ED + + def __init__( + self, + coordinator: _T, + entity_description: _ED, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + + +class WithingsMeasurementSensor( + WithingsSensor[ + WithingsMeasurementDataUpdateCoordinator, + WithingsMeasurementSensorEntityDescription, + ] +): + """Implementation of a Withings measurement sensor.""" @property - def native_value(self) -> None | str | int | float: + def native_value(self) -> float: """Return the state of the entity.""" - return self.coordinator.data[self.entity_description.measurement] + return self.coordinator.data[self.entity_description.measurement_type] @property def available(self) -> bool: """Return if the sensor is available.""" return ( super().available - and self.entity_description.measurement in self.coordinator.data + and self.entity_description.measurement_type in self.coordinator.data ) + + +class WithingsSleepSensor( + WithingsSensor[ + WithingsSleepDataUpdateCoordinator, + WithingsSleepSensorEntityDescription, + ] +): + """Implementation of a Withings sleep sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + if not self.coordinator.data: + return None + return self.entity_description.value_fn(self.coordinator.data) + + +class WithingsGoalsSensor( + WithingsSensor[ + WithingsGoalsDataUpdateCoordinator, + WithingsGoalsSensorEntityDescription, + ] +): + """Implementation of a Withings goals sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + assert self.coordinator.data + return self.entity_description.value_fn(self.coordinator.data) + + +class WithingsActivitySensor( + WithingsSensor[ + WithingsActivityDataUpdateCoordinator, + WithingsActivitySensorEntityDescription, + ] +): + """Implementation of a Withings activity sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + if not self.coordinator.data: + return None + return self.entity_description.value_fn(self.coordinator.data) + + @property + def last_reset(self) -> datetime: + """These values reset every day.""" + return dt_util.start_of_local_day() + + +class WithingsWorkoutSensor( + WithingsSensor[ + WithingsWorkoutDataUpdateCoordinator, + WithingsWorkoutSensorEntityDescription, + ] +): + """Implementation of a Withings workout sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + if not self.coordinator.data: + return None + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index a9ba69ad045..fb447f3578e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -29,6 +29,11 @@ "name": "In bed" } }, + "calendar": { + "workout": { + "name": "Workouts" + } + }, "sensor": { "fat_mass": { "name": "Fat mass" @@ -72,6 +77,18 @@ "pulse_wave_velocity": { "name": "Pulse wave velocity" }, + "vo2_max": { + "name": "VO2 max" + }, + "extracellular_water": { + "name": "Extracellular water" + }, + "intracellular_water": { + "name": "Intracellular water" + }, + "vascular_age": { + "name": "Vascular age" + }, "breathing_disturbances_intensity": { "name": "Breathing disturbances intensity" }, @@ -122,6 +139,114 @@ }, "wakeup_time": { "name": "Wakeup time" + }, + "step_goal": { + "name": "Step goal" + }, + "sleep_goal": { + "name": "Sleep goal" + }, + "weight_goal": { + "name": "Weight goal" + }, + "activity_steps_today": { + "name": "Steps today" + }, + "activity_distance_today": { + "name": "Distance travelled today" + }, + "activity_floors_climbed_today": { + "name": "Floors climbed today" + }, + "activity_soft_duration_today": { + "name": "Soft activity today" + }, + "activity_moderate_duration_today": { + "name": "Moderate activity today" + }, + "activity_intense_duration_today": { + "name": "Intense activity today" + }, + "activity_active_duration_today": { + "name": "Active time today" + }, + "activity_active_calories_burnt_today": { + "name": "Active calories burnt today" + }, + "activity_total_calories_burnt_today": { + "name": "Total calories burnt today" + }, + "workout_type": { + "name": "Last workout type", + "state": { + "walk": "Walking", + "run": "Running", + "hiking": "Hiking", + "skating": "Skating", + "bmx": "BMX", + "bicycling": "Bicycling", + "swimming": "Swimming", + "surfing": "Surfing", + "kitesurfing": "Kitesurfing", + "windsurfing": "Windsurfing", + "bodyboard": "Bodyboard", + "tennis": "Tennis", + "table_tennis": "Table tennis", + "squash": "Squash", + "badminton": "Badminton", + "lift_weights": "Lift weights", + "calisthenics": "Calisthenics", + "elliptical": "Elliptical", + "pilates": "Pilates", + "basket_ball": "Basket ball", + "soccer": "Soccer", + "football": "Football", + "rugby": "Rugby", + "volley_ball": "Volley ball", + "waterpolo": "Waterpolo", + "horse_riding": "Horse riding", + "golf": "Golf", + "yoga": "Yoga", + "dancing": "Dancing", + "boxing": "Boxing", + "fencing": "Fencing", + "wrestling": "Wrestling", + "martial_arts": "Martial arts", + "skiing": "Skiing", + "snowboarding": "Snowboarding", + "other": "Other", + "no_activity": "No activity", + "rowing": "Rowing", + "zumba": "Zumba", + "baseball": "Baseball", + "handball": "Handball", + "hockey": "Hockey", + "ice_hockey": "Ice hockey", + "climbing": "Climbing", + "ice_skating": "Ice skating", + "multi_sport": "Multi sport", + "indoor_walk": "Indoor walking", + "indoor_running": "Indoor running", + "indoor_cycling": "Indoor cycling" + } + }, + "workout_active_calories_burnt": { + "name": "Calories burnt last workout" + }, + "workout_distance": { + "name": "Distance travelled last workout" + }, + "workout_floors_climbed": { + "name": "Floors climbed last workout" + }, + "workout_intensity": { + "name": "Last workout intensity" + }, + "workout_pause_duration": { + "name": "Pause during last workout" + }, + "workout_duration": { + "name": "Last workout duration" } } } diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 1dda368a2b0..cbb78545e2b 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_KEEP_MASTER_LIGHT, DEFAULT_KEEP_MASTER_LIGHT, DOMAIN +from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): @@ -136,9 +136,9 @@ class WLEDOptionsFlowHandler(OptionsFlow): data_schema=vol.Schema( { vol.Optional( - CONF_KEEP_MASTER_LIGHT, + CONF_KEEP_MAIN_LIGHT, default=self.config_entry.options.get( - CONF_KEEP_MASTER_LIGHT, DEFAULT_KEEP_MASTER_LIGHT + CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT ), ): bool, } diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index 40f831772bc..cee9984a3f6 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -9,8 +9,8 @@ LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=10) # Options -CONF_KEEP_MASTER_LIGHT = "keep_master_light" -DEFAULT_KEEP_MASTER_LIGHT = False +CONF_KEEP_MAIN_LIGHT = "keep_master_light" +DEFAULT_KEEP_MAIN_LIGHT = False # Attributes ATTR_COLOR_PRIMARY = "color_primary" diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 6f3bae03bfa..6bbcb1747f0 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -10,8 +10,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_KEEP_MASTER_LIGHT, - DEFAULT_KEEP_MASTER_LIGHT, + CONF_KEEP_MAIN_LIGHT, + DEFAULT_KEEP_MAIN_LIGHT, DOMAIN, LOGGER, SCAN_INTERVAL, @@ -21,7 +21,7 @@ from .const import ( class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): """Class to manage fetching WLED data from single endpoint.""" - keep_master_light: bool + keep_main_light: bool config_entry: ConfigEntry def __init__( @@ -31,8 +31,8 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): entry: ConfigEntry, ) -> None: """Initialize global WLED data updater.""" - self.keep_master_light = entry.options.get( - CONF_KEEP_MASTER_LIGHT, DEFAULT_KEEP_MASTER_LIGHT + self.keep_main_light = entry.options.get( + CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT ) self.wled = WLED(entry.data[CONF_HOST], session=async_get_clientsession(hass)) self.unsub: CALLBACK_TYPE | None = None @@ -45,9 +45,9 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): ) @property - def has_master_light(self) -> bool: - """Return if the coordinated device has a master light.""" - return self.keep_master_light or ( + def has_main_light(self) -> bool: + """Return if the coordinated device has a main light.""" + return self.keep_main_light or ( self.data is not None and len(self.data.state.segments) > 1 ) diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 6675118e565..b793654c886 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -33,8 +33,8 @@ async def async_setup_entry( ) -> None: """Set up WLED light based on a config entry.""" coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - if coordinator.keep_master_light: - async_add_entities([WLEDMasterLight(coordinator=coordinator)]) + if coordinator.keep_main_light: + async_add_entities([WLEDMainLight(coordinator=coordinator)]) update_segments = partial( async_update_segments, @@ -47,8 +47,8 @@ async def async_setup_entry( update_segments() -class WLEDMasterLight(WLEDEntity, LightEntity): - """Defines a WLED master light.""" +class WLEDMainLight(WLEDEntity, LightEntity): + """Defines a WLED main light.""" _attr_color_mode = ColorMode.BRIGHTNESS _attr_icon = "mdi:led-strip-variant" @@ -57,7 +57,7 @@ class WLEDMasterLight(WLEDEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize WLED master light.""" + """Initialize WLED main light.""" super().__init__(coordinator=coordinator) self._attr_unique_id = coordinator.data.info.mac_address @@ -73,8 +73,8 @@ class WLEDMasterLight(WLEDEntity, LightEntity): @property def available(self) -> bool: - """Return if this master light is available or not.""" - return self.coordinator.has_master_light and super().available + """Return if this main light is available or not.""" + return self.coordinator.has_main_light and super().available @wled_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: @@ -167,8 +167,8 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): state = self.coordinator.data.state # If this is the one and only segment, calculate brightness based - # on the master and segment brightness - if not self.coordinator.has_master_light: + # on the main and segment brightness + if not self.coordinator.has_main_light: return int( (state.segments[self._segment].brightness * state.brightness) / 255 ) @@ -185,9 +185,9 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): """Return the state of the light.""" state = self.coordinator.data.state - # If there is no master, we take the master state into account + # If there is no main, we take the main state into account # on the segment level. - if not self.coordinator.has_master_light and not state.on: + if not self.coordinator.has_main_light and not state.on: return False return bool(state.segments[self._segment].on) @@ -200,8 +200,8 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): # WLED uses 100ms per unit, so 10 = 1 second. transition = round(kwargs[ATTR_TRANSITION] * 10) - # If there is no master control, and only 1 segment, handle the master - if not self.coordinator.has_master_light: + # If there is no main control, and only 1 segment, handle the main + if not self.coordinator.has_main_light: await self.coordinator.wled.master(on=False, transition=transition) return @@ -233,19 +233,19 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): if ATTR_EFFECT in kwargs: data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] - # If there is no master control, and only 1 segment, handle the master - if not self.coordinator.has_master_light: - master_data = {ATTR_ON: True} + # If there is no main control, and only 1 segment, handle the main + if not self.coordinator.has_main_light: + main_data = {ATTR_ON: True} if ATTR_BRIGHTNESS in data: - master_data[ATTR_BRIGHTNESS] = data[ATTR_BRIGHTNESS] + main_data[ATTR_BRIGHTNESS] = data[ATTR_BRIGHTNESS] data[ATTR_BRIGHTNESS] = 255 if ATTR_TRANSITION in data: - master_data[ATTR_TRANSITION] = data[ATTR_TRANSITION] + main_data[ATTR_TRANSITION] = data[ATTR_TRANSITION] del data[ATTR_TRANSITION] await self.coordinator.wled.segment(**data) - await self.coordinator.wled.master(**master_data) + await self.coordinator.wled.master(**main_data) return await self.coordinator.wled.segment(**data) @@ -259,13 +259,13 @@ def async_update_segments( ) -> None: """Update segments.""" segment_ids = {light.segment_id for light in coordinator.data.state.segments} - new_entities: list[WLEDMasterLight | WLEDSegmentLight] = [] + new_entities: list[WLEDMainLight | WLEDSegmentLight] = [] - # More than 1 segment now? No master? Add master controls - if not coordinator.keep_master_light and ( + # More than 1 segment now? No main? Add main controls + if not coordinator.keep_main_light and ( len(current_ids) < 2 and len(segment_ids) > 1 ): - new_entities.append(WLEDMasterLight(coordinator)) + new_entities.append(WLEDMainLight(coordinator)) # Process new segments, add them to Home Assistant for segment_id in segment_ids - current_ids: diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index b6d205912c6..b6e14963b9e 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.16.0"], + "requirements": ["wled==0.17.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 5791732dfbe..61b9cc450fe 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -26,7 +26,7 @@ "step": { "init": { "data": { - "keep_master_light": "Keep master light, even with 1 LED segment." + "keep_master_light": "Keep main light, even with 1 LED segment." } } } diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 5daea6ce129..6a541cc84e1 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,28 +2,19 @@ from __future__ import annotations from datetime import date, timedelta -from typing import Any from holidays import ( HolidayBase, __version__ as python_holidays_version, country_holidays, - list_supported_countries, ) -import voluptuous as vol -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - BinarySensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from .const import ( @@ -35,10 +26,6 @@ from .const import ( CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, - DEFAULT_EXCLUDES, - DEFAULT_NAME, - DEFAULT_OFFSET, - DEFAULT_WORKDAYS, DOMAIN, LOGGER, ) @@ -64,76 +51,6 @@ def validate_dates(holiday_list: list[str]) -> list[str]: return calc_holidays -def valid_country(value: Any) -> str: - """Validate that the given country is supported.""" - value = cv.string(value) - - try: - raw_value = value.encode("utf-8") - except UnicodeError as err: - raise vol.Invalid( - "The country name or the abbreviation must be a valid UTF-8 string." - ) from err - if not raw_value: - raise vol.Invalid("Country name or the abbreviation must not be empty.") - if value not in list_supported_countries(): - raise vol.Invalid("Country is not supported.") - return value - - -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COUNTRY): valid_country, - vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): vol.All( - cv.ensure_list, [vol.In(ALLOWED_DAYS)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int), - vol.Optional(CONF_PROVINCE): cv.string, - vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): vol.All( - cv.ensure_list, [vol.In(ALLOWED_DAYS)] - ), - vol.Optional(CONF_ADD_HOLIDAYS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Workday sensor.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.11.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Workday", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 6be7e119876..907f5c5bdb5 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -16,6 +16,8 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, NumberSelector, NumberSelectorConfig, NumberSelectorMode, @@ -43,23 +45,23 @@ from .const import ( LOGGER, ) -NONE_SENTINEL = "none" - def add_province_to_schema( schema: vol.Schema, - country: str, + country: str | None, ) -> vol.Schema: """Update schema with province from country.""" + if not country: + return schema + all_countries = list_supported_countries() if not all_countries.get(country): return schema - province_list = [NONE_SENTINEL, *all_countries[country]] add_schema = { - vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=province_list, + options=all_countries[country], mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) @@ -90,7 +92,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: raise AddDatesError("Incorrect date") year: int = dt_util.now().year - if country := user_input[CONF_COUNTRY]: + if country := user_input.get(CONF_COUNTRY): cls = country_holidays(country) obj_holidays = country_holidays( country=country, @@ -113,11 +115,9 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: DATA_SCHEMA_SETUP = vol.Schema( { vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), - vol.Optional(CONF_COUNTRY, default=NONE_SENTINEL): SelectSelector( - SelectSelectorConfig( - options=[NONE_SENTINEL, *list(list_supported_countries())], - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_COUNTRY, + vol.Optional(CONF_COUNTRY): CountrySelector( + CountrySelectorConfig( + countries=list(list_supported_countries()), ) ), } @@ -179,33 +179,6 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): """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) - LOGGER.debug("Importing with %s", new_config) - - self._async_abort_entries_match(abort_match) - - self.data[CONF_NAME] = config.get(CONF_NAME, DEFAULT_NAME) - self.data[CONF_COUNTRY] = config[CONF_COUNTRY] - LOGGER.debug( - "No duplicate, next step with name %s for country %s", - self.data[CONF_NAME], - self.data[CONF_COUNTRY], - ) - return await self.async_step_options(user_input=new_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -229,11 +202,6 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: combined_input: dict[str, Any] = {**self.data, **user_input} - if combined_input.get(CONF_COUNTRY, NONE_SENTINEL) == NONE_SENTINEL: - combined_input[CONF_COUNTRY] = None - 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 @@ -246,17 +214,15 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): errors["remove_holidays"] = "remove_holiday_error" except RemoveDateRangeError: errors["remove_holidays"] = "remove_holiday_range_error" - except NotImplementedError: - self.async_abort(reason="incorrect_province") abort_match = { - CONF_COUNTRY: combined_input[CONF_COUNTRY], + CONF_COUNTRY: combined_input.get(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], + CONF_PROVINCE: combined_input.get(CONF_PROVINCE), } LOGGER.debug("abort_check in options with %s", combined_input) self._async_abort_entries_match(abort_match) @@ -271,7 +237,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.data[CONF_COUNTRY] + add_province_to_schema, DATA_SCHEMA_OPT, self.data.get(CONF_COUNTRY) ) new_schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form( @@ -280,7 +246,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={ "name": self.data[CONF_NAME], - "country": self.data[CONF_COUNTRY], + "country": self.data.get(CONF_COUNTRY), }, ) @@ -296,8 +262,9 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): 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 + if CONF_PROVINCE not in user_input: + # Province not present, delete old value (if present) too + combined_input.pop(CONF_PROVINCE, None) try: await self.hass.async_add_executor_job( @@ -316,13 +283,13 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): try: self._async_abort_entries_match( { - CONF_COUNTRY: self._config_entry.options[CONF_COUNTRY], + CONF_COUNTRY: self._config_entry.options.get(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], + CONF_PROVINCE: combined_input.get(CONF_PROVINCE), } ) except AbortFlow as err: @@ -331,7 +298,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): return self.async_create_entry(data=combined_input) schema: vol.Schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.options[CONF_COUNTRY] + add_province_to_schema, DATA_SCHEMA_OPT, self.options.get(CONF_COUNTRY) ) new_schema = self.add_suggested_values_to_schema( @@ -344,7 +311,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): errors=errors, description_placeholders={ "name": self.options[CONF_NAME], - "country": self.options[CONF_COUNTRY], + "country": self.options.get(CONF_COUNTRY), }, ) diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index ff643ecc2cb..daafd0396b8 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -18,7 +18,6 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .config_flow import NONE_SENTINEL from .const import CONF_PROVINCE @@ -75,9 +74,8 @@ class CountryFixFlow(RepairsFlow): self, user_input: dict[str, Any] | None = None ) -> data_entry_flow.FlowResult: """Handle the province step of a fix flow.""" - if user_input and user_input.get(CONF_PROVINCE): - if user_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: - user_input[CONF_PROVINCE] = None + if user_input is not None: + user_input.setdefault(CONF_PROVINCE, None) options = dict(self.entry.options) new_options = {**options, **user_input, CONF_COUNTRY: self.country} self.hass.config_entries.async_update_entry(self.entry, options=new_options) @@ -90,9 +88,9 @@ class CountryFixFlow(RepairsFlow): step_id="province", data_schema=vol.Schema( { - vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=[NONE_SENTINEL, *country_provinces], + options=country_provinces, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index a4c2baf31c8..d0ffecd0f7e 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -2,7 +2,6 @@ "title": "Workday", "config": { "abort": { - "incorrect_province": "Incorrect subdivision from yaml import", "already_configured": "Workday has already been setup with chosen configuration" }, "step": { @@ -70,11 +69,6 @@ } }, "selector": { - "country": { - "options": { - "none": "No country" - } - }, "province": { "options": { "none": "No subdivision" diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index b12f4df7db1..ced8c3cc471 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -15,7 +15,11 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry, async_get +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceRegistry, + async_get, +) from .const import ( CONF_DISCOVERED_EVENT_CLASSES, @@ -55,6 +59,7 @@ def process_service_info( sensor_device_info = update.devices[device_key.device_id] device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, + connections={(CONNECTION_BLUETOOTH, address)}, identifiers={(BLUETOOTH_DOMAIN, address)}, manufacturer=sensor_device_info.manufacturer, model=sensor_device_info.model, diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 970de13bcef..d1bc6fa9a48 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 0291ca2c8bd..3c316fd3f47 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -296,10 +296,16 @@ 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 + # List of models requiring specific lazy_discover setting + LAZY_DISCOVER_FOR_MODEL = { + "zhimi.fan.za5": True, + "zhimi.airpurifier.za1": True, + } + lazy_discover = LAZY_DISCOVER_FOR_MODEL.get(model, False) + if ( model not in MODELS_HUMIDIFIER and model not in MODELS_FAN diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index cbff581d296..8d15fbb9a9f 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.3.0"] + "requirements": ["yalexs-ble==2.3.1"] } diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index a442540109a..c5cd6f906f5 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -105,6 +105,7 @@ EFFECT_SUNSET = "Sunset" EFFECT_ROMANCE = "Romance" EFFECT_HAPPY_BIRTHDAY = "Happy Birthday" EFFECT_CANDLE_FLICKER = "Candle Flicker" +EFFECT_TEA_TIME = "Tea Time" YEELIGHT_TEMP_ONLY_EFFECT_LIST = [EFFECT_TEMP, EFFECT_STOP] @@ -118,6 +119,7 @@ YEELIGHT_MONO_EFFECT_LIST = [ EFFECT_TWITTER, EFFECT_HOME, EFFECT_CANDLE_FLICKER, + EFFECT_TEA_TIME, *YEELIGHT_TEMP_ONLY_EFFECT_LIST, ] @@ -162,6 +164,7 @@ EFFECTS_MAP = { EFFECT_ROMANCE: flows.romance, EFFECT_HAPPY_BIRTHDAY: flows.happy_birthday, EFFECT_CANDLE_FLICKER: flows.candle_flicker, + EFFECT_TEA_TIME: flows.tea_time, } VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Range(min=1, max=100)) diff --git a/homeassistant/components/yeelight/services.yaml b/homeassistant/components/yeelight/services.yaml index ccfd46ef680..825835dbcc7 100644 --- a/homeassistant/components/yeelight/services.yaml +++ b/homeassistant/components/yeelight/services.yaml @@ -25,7 +25,7 @@ set_color_scene: rgb_color: example: "[255, 100, 100]" selector: - object: + color_rgb: brightness: selector: number: diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 4c76a0c46ef..8509d8133e2 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.115.2"] + "requirements": ["zeroconf==0.119.0"] } diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 08db98cff6f..222c7f1d4ef 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -8,12 +8,13 @@ import re import voluptuous as vol from zhaquirks import setup as setup_quirks -from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.exceptions import NetworkSettingsInconsistent from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -26,7 +27,6 @@ from .core.const import ( BAUD_RATES, CONF_BAUDRATE, CONF_CUSTOM_QUIRKS_PATH, - CONF_DATABASE, CONF_DEVICE_CONFIG, CONF_ENABLE_QUIRKS, CONF_RADIO_TYPE, @@ -42,6 +42,11 @@ from .core.device import get_device_automation_triggers from .core.discovery import GROUP_PROBE from .core.helpers import ZHAData, get_zha_data from .radio_manager import ZhaRadioManager +from .repairs.network_settings_inconsistent import warn_on_inconsistent_network_settings +from .repairs.wrong_silabs_firmware import ( + AlreadyRunningEZSP, + warn_on_wrong_silabs_firmware, +) DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string}) ZHA_CONFIG_SCHEMA = { @@ -155,28 +160,25 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) - async def async_zha_shutdown(): - """Handle shutdown tasks.""" - await zha_gateway.shutdown() - # clean up any remaining entity metadata - # (entities that have been discovered but not yet added to HA) - # suppress KeyError because we don't know what state we may - # be in when we get here in failure cases - with contextlib.suppress(KeyError): - for platform in PLATFORMS: - del zha_data.platforms[platform] - - config_entry.async_on_unload(async_zha_shutdown) - try: await zha_gateway.async_initialize() + except NetworkSettingsInconsistent as exc: + await warn_on_inconsistent_network_settings( + hass, + config_entry=config_entry, + old_state=exc.old_state, + new_state=exc.new_state, + ) + raise HomeAssistantError( + "Network settings do not match most recent backup" + ) from exc except Exception: if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: try: - await repairs.warn_on_wrong_silabs_firmware( + await warn_on_wrong_silabs_firmware( hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] ) - except repairs.AlreadyRunningEZSP as exc: + except AlreadyRunningEZSP as exc: # If connecting fails but we somehow probe EZSP (e.g. stuck in the # bootloader), reconnect, it should work raise ConfigEntryNotReady from exc @@ -196,6 +198,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b websocket_api.async_load_api(hass) + async def async_shutdown(_: Event) -> None: + await zha_gateway.shutdown() + + config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown) + ) + await zha_gateway.async_initialize_devices_and_entities() await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) @@ -205,7 +214,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" zha_data = get_zha_data(hass) - zha_data.gateway = None + + if zha_data.gateway is not None: + await zha_data.gateway.shutdown() + zha_data.gateway = None + + # clean up any remaining entity metadata + # (entities that have been discovered but not yet added to HA) + # suppress KeyError because we don't know what state we may + # be in when we get here in failure cases + with contextlib.suppress(KeyError): + for platform in PLATFORMS: + del zha_data.platforms[platform] GROUP_PROBE.cleanup() websocket_api.async_unload_api(hass) diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 21cacfa5dd4..bb7cfe67fb3 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -81,7 +81,7 @@ async def async_setup_entry( class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Entity for ZHA alarm control devices.""" - _attr_name: str = "Alarm control panel" + _attr_translation_key: str = "alarm_control_panel" _attr_code_format = CodeFormat.TEXT _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index f63fb9d09de..db0658eb632 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -10,8 +10,8 @@ from zigpy.types import Channels from zigpy.util import pick_optimal_channel from .core.const import CONF_RADIO_TYPE, DOMAIN, RadioType -from .core.gateway import ZHAGateway -from .core.helpers import get_zha_data, get_zha_gateway +from .core.helpers import get_zha_gateway +from .radio_manager import ZhaRadioManager if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -55,19 +55,13 @@ async def async_get_last_network_settings( if config_entry is None: config_entry = _get_config_entry(hass) - config = get_zha_data(hass).yaml_config - zha_gateway = ZHAGateway(hass, config, config_entry) + radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) - app_controller_cls, app_config = zha_gateway.get_application_controller_data() - app = app_controller_cls(app_config) - - try: - await app._load_db() # pylint: disable=protected-access - settings = max(app.backups, key=lambda b: b.backup_time) - except ValueError: - settings = None - finally: - await app.shutdown() + async with radio_mgr.connect_zigpy_app() as app: + try: + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None return settings diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index c32bd5eeb67..9b057a3cbc3 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -43,15 +43,6 @@ IAS_ZONE_CLASS_MAPPING = { 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) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BINARY_SENSOR) CONFIG_DIAGNOSTIC_MATCH = functools.partial( @@ -81,7 +72,7 @@ async def async_setup_entry( class BinarySensor(ZhaEntity, BinarySensorEntity): """ZHA BinarySensor.""" - SENSOR_ATTR: str | None = None + _attribute_name: str def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize the ZHA binary sensor.""" @@ -98,7 +89,7 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return True if the switch is on based on the state machine.""" - raw_state = self._cluster_handler.cluster.get(self.SENSOR_ATTR) + raw_state = self._cluster_handler.cluster.get(self._attribute_name) if raw_state is None: return False return self.parse(raw_state) @@ -118,17 +109,16 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): class Accelerometer(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "acceleration" - _attr_name: str = "Accelerometer" + _attribute_name = "acceleration" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOVING + _attr_translation_key: str = "accelerometer" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY) class Occupancy(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "occupancy" - _attr_name: str = "Occupancy" + _attribute_name = "occupancy" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY @@ -136,13 +126,14 @@ class Occupancy(BinarySensor): class HueOccupancy(Occupancy): """ZHA Hue occupancy.""" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY + @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class Opening(BinarySensor): """ZHA OnOff BinarySensor.""" - SENSOR_ATTR = "on_off" - _attr_name: str = "Opening" + _attribute_name = "on_off" _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. @@ -151,7 +142,7 @@ class Opening(BinarySensor): def async_restore_last_state(self, last_state): """Restore previous state to zigpy cache.""" self._cluster_handler.cluster.update_attribute( - OnOff.attributes_by_name[self.SENSOR_ATTR].id, + OnOff.attributes_by_name[self._attribute_name].id, t.Bool.true if last_state.state == STATE_ON else t.Bool.false, ) @@ -160,8 +151,8 @@ class Opening(BinarySensor): class BinaryInput(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "present_value" - _attr_name: str = "Binary input" + _attribute_name = "present_value" + _attr_translation_key: str = "binary_input" @STRICT_MATCH( @@ -179,7 +170,6 @@ class BinaryInput(BinarySensor): class Motion(Opening): """ZHA OnOff BinarySensor with motion device class.""" - _attr_name: str = "Motion" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOTION @@ -187,13 +177,15 @@ class Motion(Opening): class IASZone(BinarySensor): """ZHA IAS BinarySensor.""" - SENSOR_ATTR = "zone_status" + _attribute_name = "zone_status" @property - def name(self) -> str | None: + def translation_key(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") + if zone_type in IAS_ZONE_CLASS_MAPPING: + return None + return "ias_zone" @property def device_class(self) -> BinarySensorDeviceClass | None: @@ -233,7 +225,7 @@ class IASZone(BinarySensor): migrated_state = IasZone.ZoneStatus(0) self._cluster_handler.cluster.update_attribute( - IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + IasZone.attributes_by_name[self._attribute_name].id, migrated_state ) @@ -241,8 +233,7 @@ class IASZone(BinarySensor): class SinopeLeakStatus(BinarySensor): """Sinope water leak sensor.""" - SENSOR_ATTR = "leak_status" - _attr_name = "Moisture" + _attribute_name = "leak_status" _attr_device_class = BinarySensorDeviceClass.MOISTURE @@ -252,89 +243,96 @@ class SinopeLeakStatus(BinarySensor): "_TZE200_htnnfasr", }, ) -class FrostLock(BinarySensor, id_suffix="frost_lock"): +class FrostLock(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "frost_lock" + _attribute_name = "frost_lock" + _unique_id_suffix = "frost_lock" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK - _attr_name: str = "Frost lock" + _attr_translation_key: str = "frost_lock" @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") -class ReplaceFilter(BinarySensor, id_suffix="replace_filter"): +class ReplaceFilter(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "replace_filter" + _attribute_name = "replace_filter" + _unique_id_suffix = "replace_filter" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC - _attr_name: str = "Replace filter" + _attr_translation_key: str = "replace_filter" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) -class AqaraPetFeederErrorDetected(BinarySensor, id_suffix="error_detected"): +class AqaraPetFeederErrorDetected(BinarySensor): """ZHA aqara pet feeder error detected binary sensor.""" - SENSOR_ATTR = "error_detected" + _attribute_name = "error_detected" + _unique_id_suffix = "error_detected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM - _attr_name: str = "Error detected" @MULTI_MATCH( cluster_handler_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, ) -class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): +class XiaomiPlugConsumerConnected(BinarySensor): """ZHA Xiaomi plug consumer connected binary sensor.""" - SENSOR_ATTR = "consumer_connected" - _attr_name: str = "Consumer connected" + _attribute_name = "consumer_connected" + _unique_id_suffix = "consumer_connected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG + _attr_translation_key: str = "consumer_connected" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) -class AqaraThermostatWindowOpen(BinarySensor, id_suffix="window_open"): +class AqaraThermostatWindowOpen(BinarySensor): """ZHA Aqara thermostat window open binary sensor.""" - SENSOR_ATTR = "window_open" + _attribute_name = "window_open" + _unique_id_suffix = "window_open" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW - _attr_name: str = "Window open" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) -class AqaraThermostatValveAlarm(BinarySensor, id_suffix="valve_alarm"): +class AqaraThermostatValveAlarm(BinarySensor): """ZHA Aqara thermostat valve alarm binary sensor.""" - SENSOR_ATTR = "valve_alarm" + _attribute_name = "valve_alarm" + _unique_id_suffix = "valve_alarm" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM - _attr_name: str = "Valve alarm" + _attr_translation_key: str = "valve_alarm" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatCalibrated(BinarySensor, id_suffix="calibrated"): +class AqaraThermostatCalibrated(BinarySensor): """ZHA Aqara thermostat calibrated binary sensor.""" - SENSOR_ATTR = "calibrated" + _attribute_name = "calibrated" + _unique_id_suffix = "calibrated" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC - _attr_name: str = "Calibrated" + _attr_translation_key: str = "calibrated" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): +class AqaraThermostatExternalSensor(BinarySensor): """ZHA Aqara thermostat external sensor binary sensor.""" - SENSOR_ATTR = "sensor" + _attribute_name = "sensor" + _unique_id_suffix = "sensor" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC - _attr_name: str = "External sensor" + _attr_translation_key: str = "external_sensor" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) -class AqaraLinkageAlarmState(BinarySensor, id_suffix="linkage_alarm_state"): +class AqaraLinkageAlarmState(BinarySensor): """ZHA Aqara linkage alarm state binary sensor.""" - SENSOR_ATTR = "linkage_alarm_state" - _attr_name: str = "Linkage alarm state" + _attribute_name = "linkage_alarm_state" + _unique_id_suffix = "linkage_alarm_state" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE + _attr_translation_key: str = "linkage_alarm_state" diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 4114a3dea7c..e16ae082eda 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -105,7 +105,6 @@ class ZHAIdentifyButton(ZHAButton): _attr_device_class = ButtonDeviceClass.IDENTIFY _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name = "Identify" _command_name = "identify" def get_args(self) -> list[Any]: @@ -145,47 +144,49 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): "_TZE200_htnnfasr", }, ) -class FrostLockResetButton(ZHAAttributeButton, id_suffix="reset_frost_lock"): +class FrostLockResetButton(ZHAAttributeButton): """Defines a ZHA frost lock reset button.""" + _unique_id_suffix = "reset_frost_lock" _attribute_name = "frost_lock_reset" - _attr_name = "Frost lock reset" _attribute_value = 0 _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "reset_frost_lock" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} ) -class NoPresenceStatusResetButton( - ZHAAttributeButton, id_suffix="reset_no_presence_status" -): +class NoPresenceStatusResetButton(ZHAAttributeButton): """Defines a ZHA no presence status reset button.""" + _unique_id_suffix = "reset_no_presence_status" _attribute_name = "reset_no_presence_status" - _attr_name = "Presence status reset" _attribute_value = 1 _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "reset_no_presence_status" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) -class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): +class AqaraPetFeederFeedButton(ZHAAttributeButton): """Defines a feed button for the aqara c1 pet feeder.""" + _unique_id_suffix = "feeding" _attribute_name = "feeding" - _attr_name = "Feed" _attribute_value = 1 + _attr_translation_key = "feed" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraSelfTestButton(ZHAAttributeButton, id_suffix="self_test"): +class AqaraSelfTestButton(ZHAAttributeButton): """Defines a ZHA self-test button for Aqara smoke sensors.""" + _unique_id_suffix = "self_test" _attribute_name = "self_test" - _attr_name = "Self-test" _attribute_value = 1 _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "self_test" diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 5cbe2684ab4..95abaf1c83e 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -140,7 +140,7 @@ class Thermostat(ZhaEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_name: str = "Thermostat" + _attr_translation_key: str = "thermostat" def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" @@ -367,10 +367,10 @@ class Thermostat(ZhaEntity, ClimateEntity): self._thrm, SIGNAL_ATTR_UPDATED, self.async_attribute_updated ) - async def async_attribute_updated(self, record): + async def async_attribute_updated(self, attr_id, attr_name, value): """Handle attribute update from device.""" if ( - record.attr_name in (ATTR_OCCP_COOL_SETPT, ATTR_OCCP_HEAT_SETPT) + attr_name in (ATTR_OCCP_COOL_SETPT, ATTR_OCCP_HEAT_SETPT) and self.preset_mode == PRESET_AWAY ): # occupancy attribute is an unreportable attribute, but if we get @@ -379,7 +379,7 @@ class Thermostat(ZhaEntity, ClimateEntity): if await self._thrm.get_occupancy() is True: self._preset = PRESET_NONE - self.debug("Attribute '%s' = %s update", record.attr_name, record.value) + self.debug("Attribute '%s' = %s update", attr_name, value) self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: @@ -609,24 +609,24 @@ class MoesThermostat(Thermostat): """Return only the heat mode, because the device can't be turned off.""" return [HVACMode.HEAT] - async def async_attribute_updated(self, record): + async def async_attribute_updated(self, attr_id, attr_name, value): """Handle attribute update from device.""" - if record.attr_name == "operation_preset": - if record.value == 0: + if attr_name == "operation_preset": + if value == 0: self._preset = PRESET_AWAY - if record.value == 1: + if value == 1: self._preset = PRESET_SCHEDULE - if record.value == 2: + if value == 2: self._preset = PRESET_NONE - if record.value == 3: + if value == 3: self._preset = PRESET_COMFORT - if record.value == 4: + if value == 4: self._preset = PRESET_ECO - if record.value == 5: + if value == 5: self._preset = PRESET_BOOST - if record.value == 6: + if value == 6: self._preset = PRESET_COMPLEX - await super().async_attribute_updated(record) + await super().async_attribute_updated(attr_id, attr_name, value) async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode.""" @@ -688,22 +688,22 @@ class BecaThermostat(Thermostat): """Return only the heat mode, because the device can't be turned off.""" return [HVACMode.HEAT] - async def async_attribute_updated(self, record): + async def async_attribute_updated(self, attr_id, attr_name, value): """Handle attribute update from device.""" - if record.attr_name == "operation_preset": - if record.value == 0: + if attr_name == "operation_preset": + if value == 0: self._preset = PRESET_AWAY - if record.value == 1: + if value == 1: self._preset = PRESET_SCHEDULE - if record.value == 2: + if value == 2: self._preset = PRESET_NONE - if record.value == 4: + if value == 4: self._preset = PRESET_ECO - if record.value == 5: + if value == 5: self._preset = PRESET_BOOST - if record.value == 7: + if value == 7: self._preset = PRESET_TEMP_MANUAL - await super().async_attribute_updated(record) + await super().async_attribute_updated(attr_id, attr_name, value) async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode.""" @@ -783,20 +783,20 @@ class ZONNSMARTThermostat(Thermostat): ] self._supported_flags |= ClimateEntityFeature.PRESET_MODE - async def async_attribute_updated(self, record): + async def async_attribute_updated(self, attr_id, attr_name, value): """Handle attribute update from device.""" - if record.attr_name == "operation_preset": - if record.value == 0: + if attr_name == "operation_preset": + if value == 0: self._preset = PRESET_SCHEDULE - if record.value == 1: + if value == 1: self._preset = PRESET_NONE - if record.value == 2: + if value == 2: self._preset = self.PRESET_HOLIDAY - if record.value == 3: + if value == 3: self._preset = self.PRESET_HOLIDAY - if record.value == 4: + if value == 4: self._preset = self.PRESET_FROST - await super().async_attribute_updated(record) + await super().async_attribute_updated(attr_id, attr_name, value) async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 4262a16800d..980a6f88a75 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -124,11 +124,19 @@ class WindowCoveringClient(ClientClusterHandler): class WindowCovering(ClusterHandler): """Window cluster handler.""" - _value_attribute = 8 + _value_attribute_lift = ( + closures.WindowCovering.AttributeDefs.current_position_lift_percentage.id + ) + _value_attribute_tilt = ( + closures.WindowCovering.AttributeDefs.current_position_tilt_percentage.id + ) REPORT_CONFIG = ( AttrReportConfig( attr="current_position_lift_percentage", config=REPORT_CONFIG_IMMEDIATE ), + AttrReportConfig( + attr="current_position_tilt_percentage", config=REPORT_CONFIG_IMMEDIATE + ), ) async def async_update(self): @@ -140,10 +148,21 @@ class WindowCovering(ClusterHandler): if result is not None: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - 8, + self._value_attribute_lift, "current_position_lift_percentage", result, ) + result = await self.get_attribute_value( + "current_position_tilt_percentage", from_cache=False + ) + self.debug("read current tilt position: %s", result) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self._value_attribute_tilt, + "current_position_tilt_percentage", + result, + ) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: @@ -152,7 +171,7 @@ class WindowCovering(ClusterHandler): self.debug( "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) - if attrid == self._value_attribute: + if attrid in (self._value_attribute_lift, self._value_attribute_tilt): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) diff --git a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py index 8ca014f453e..a379db54dac 100644 --- a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py +++ b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py @@ -87,6 +87,7 @@ class ElectricalMeasurementClusterHandler(ClusterHandler): "measurement_type": True, "power_divisor": True, "power_multiplier": True, + "power_factor": True, } async def async_update(self): diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index 15050ce67b1..dad3ee5eb4d 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -5,7 +5,6 @@ https://home-assistant.io/integrations/zha/ """ from __future__ import annotations -from collections import namedtuple from typing import Any from zigpy.zcl.clusters import hvac @@ -21,7 +20,6 @@ from ..const import ( ) from . import AttrReportConfig, ClusterHandler -AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) 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) @@ -235,7 +233,9 @@ class ThermostatClusterHandler(ClusterHandler): ) self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - AttributeUpdateRecord(attrid, attr_name, value), + attrid, + attr_name, + value, ) async def async_set_operation_mode(self, mode) -> bool: diff --git a/homeassistant/components/zha/core/cluster_handlers/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py index 5f54ce381cc..5f1e52fa241 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -25,14 +25,10 @@ class ColorClientClusterHandler(ClientClusterHandler): class ColorClusterHandler(ClusterHandler): """Color cluster handler.""" - CAPABILITIES_COLOR_XY = 0x08 - CAPABILITIES_COLOR_TEMP = 0x10 - UNSUPPORTED_ATTRIBUTE = 0x86 REPORT_CONFIG = ( AttrReportConfig(attr="current_x", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="current_y", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="current_hue", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="enhanced_current_hue", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="current_saturation", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="color_temperature", config=REPORT_CONFIG_DEFAULT), ) @@ -44,6 +40,7 @@ class ColorClusterHandler(ClusterHandler): "color_temp_physical_max": True, "color_capabilities": True, "color_loop_active": False, + "enhanced_current_hue": False, "start_up_color_temperature": True, "options": True, } @@ -53,7 +50,7 @@ class ColorClusterHandler(ClusterHandler): """Return ZCL color capabilities of the light.""" color_capabilities = self.cluster.get("color_capabilities") if color_capabilities is None: - return lighting.Color.ColorCapabilities(self.CAPABILITIES_COLOR_XY) + return lighting.Color.ColorCapabilities.XY_attributes return lighting.Color.ColorCapabilities(color_capabilities) @property diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index b37fa7ffe6d..9874fddc598 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -7,7 +7,6 @@ import logging import bellows.zigbee.application import voluptuous as vol import zigpy.application -from zigpy.config import CONF_DEVICE_PATH # noqa: F401 import zigpy.types as t import zigpy_deconz.zigbee.application import zigpy_xbee.zigbee.application @@ -49,6 +48,7 @@ ATTR_POWER_SOURCE = "power_source" ATTR_PROFILE_ID = "profile_id" ATTR_QUIRK_APPLIED = "quirk_applied" ATTR_QUIRK_CLASS = "quirk_class" +ATTR_QUIRK_ID = "quirk_id" ATTR_ROUTES = "routes" ATTR_RSSI = "rssi" ATTR_SIGNATURE = "signature" @@ -128,7 +128,6 @@ CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code" CONF_BAUDRATE = "baudrate" CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path" -CONF_DATABASE = "database_path" CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition" CONF_DEVICE_CONFIG = "device_config" CONF_ENABLE_ENHANCED_LIGHT_TRANSITION = "enhanced_light_transition" @@ -138,8 +137,6 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" -CONF_NWK = "network" -CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 8f5b087f068..44acbb172fc 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -59,6 +59,7 @@ from .const import ( ATTR_POWER_SOURCE, ATTR_QUIRK_APPLIED, ATTR_QUIRK_CLASS, + ATTR_QUIRK_ID, ATTR_ROUTES, ATTR_RSSI, ATTR_SIGNATURE, @@ -135,6 +136,7 @@ class ZHADevice(LogMixin): f"{self._zigpy_device.__class__.__module__}." f"{self._zigpy_device.__class__.__name__}" ) + self.quirk_id = getattr(self._zigpy_device, ATTR_QUIRK_ID, None) if self.is_mains_powered: self.consider_unavailable_time = async_get_zha_config_value( @@ -537,6 +539,7 @@ class ZHADevice(LogMixin): ATTR_NAME: self.name or ieee, ATTR_QUIRK_APPLIED: self.quirk_applied, ATTR_QUIRK_CLASS: self.quirk_class, + ATTR_QUIRK_ID: self.quirk_id, ATTR_MANUFACTURER_CODE: self.manufacturer_code, ATTR_POWER_SOURCE: self.power_source, ATTR_LQI: self.lqi, diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index a56e7044d3a..90ed68f9b00 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -122,7 +122,7 @@ class ProbeEndpoint: endpoint.device.manufacturer, endpoint.device.model, cluster_handlers, - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) if platform_entity_class is None: return @@ -181,7 +181,7 @@ class ProbeEndpoint: endpoint.device.manufacturer, endpoint.device.model, cluster_handler_list, - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) if entity_class is None: return @@ -226,14 +226,14 @@ class ProbeEndpoint: endpoint.device.manufacturer, endpoint.device.model, list(endpoint.all_cluster_handlers.values()), - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) else: matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( endpoint.device.manufacturer, endpoint.device.model, endpoint.unclaimed_cluster_handlers(), - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) endpoint.claim_cluster_handlers(claimed) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index c5d04dda961..b4c02d33015 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import collections from collections.abc import Callable +from contextlib import suppress from datetime import timedelta from enum import Enum import itertools @@ -13,10 +14,17 @@ import time from typing import TYPE_CHECKING, Any, NamedTuple from zigpy.application import ControllerApplication -from zigpy.config import CONF_DEVICE +from zigpy.config import ( + CONF_DATABASE, + CONF_DEVICE, + CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, + CONF_NWK_VALIDATE_SETTINGS, +) import zigpy.device import zigpy.endpoint -import zigpy.exceptions +from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError import zigpy.group from zigpy.types.named import EUI64 @@ -38,10 +46,6 @@ from .const import ( ATTR_NWK, ATTR_SIGNATURE, ATTR_TYPE, - CONF_DATABASE, - CONF_DEVICE_PATH, - CONF_NWK, - CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -159,6 +163,9 @@ class ZHAGateway: app_config[CONF_DATABASE] = database app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] + if CONF_NWK_VALIDATE_SETTINGS not in app_config: + app_config[CONF_NWK_VALIDATE_SETTINGS] = True + # The bellows UART thread sometimes propagates a cancellation into the main Core # event loop, when a connection to a TCP coordinator fails in a specific way if ( @@ -196,26 +203,33 @@ class ZHAGateway: start_radio=False, ) - for attempt in range(STARTUP_RETRIES): - try: - await self.application_controller.startup(auto_form=True) - except zigpy.exceptions.TransientConnectionError as exc: - raise ConfigEntryNotReady from exc - except Exception as exc: # pylint: disable=broad-except - _LOGGER.warning( - "Couldn't start %s coordinator (attempt %s of %s)", - self.radio_description, - attempt + 1, - STARTUP_RETRIES, - exc_info=exc, - ) + try: + for attempt in range(STARTUP_RETRIES): + try: + await self.application_controller.startup(auto_form=True) + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except NetworkSettingsInconsistent: + raise + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug( + "Couldn't start %s coordinator (attempt %s of %s)", + self.radio_description, + attempt + 1, + STARTUP_RETRIES, + exc_info=exc, + ) - if attempt == STARTUP_RETRIES - 1: - raise exc + if attempt == STARTUP_RETRIES - 1: + raise exc - await asyncio.sleep(STARTUP_FAILURE_DELAY_S) - else: - break + await asyncio.sleep(STARTUP_FAILURE_DELAY_S) + else: + break + except Exception: + # Explicitly shut down the controller application on failure + await self.application_controller.shutdown() + raise zha_data = get_zha_data(self.hass) zha_data.gateway = self @@ -231,12 +245,13 @@ class ZHAGateway: self.application_controller.groups.add_listener(self) def _find_coordinator_device(self) -> zigpy.device.Device: + zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) + if last_backup := self.application_controller.backups.most_recent_backup(): - zigpy_coordinator = self.application_controller.get_device( - ieee=last_backup.node_info.ieee - ) - else: - zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) + with suppress(KeyError): + zigpy_coordinator = self.application_controller.get_device( + ieee=last_backup.node_info.ieee + ) return zigpy_coordinator diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 4df546b449c..0246c1e4b1c 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -397,6 +397,15 @@ QR_CODES = ( ([0-9a-fA-F]{36}) # install code $ """, + # Bosch + r""" + ^RB01SG + [0-9a-fA-F]{34} + ([0-9a-fA-F]{16}) # IEEE address + DLK + ([0-9a-fA-F]{36}) # install code + $ + """, ) @@ -437,7 +446,10 @@ class ZHAData: def get_zha_data(hass: HomeAssistant) -> ZHAData: """Get the global ZHA data object.""" - return hass.data.get(DATA_ZHA, ZHAData()) + if DATA_ZHA not in hass.data: + hass.data[DATA_ZHA] = ZHAData() + + return hass.data[DATA_ZHA] def get_zha_gateway(hass: HomeAssistant) -> ZHAGateway: diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 74f724bdc49..4bdedebfff9 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -147,7 +147,7 @@ class MatchRule: aux_cluster_handlers: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) - quirk_classes: frozenset[str] | Callable = attr.ib( + quirk_ids: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) @@ -165,10 +165,8 @@ class MatchRule: multiple cluster handlers a better priority over rules matching a single cluster handler. """ weight = 0 - if self.quirk_classes: - weight += 501 - ( - 1 if callable(self.quirk_classes) else len(self.quirk_classes) - ) + if self.quirk_ids: + weight += 501 - (1 if callable(self.quirk_ids) else len(self.quirk_ids)) if self.models: weight += 401 - (1 if callable(self.models) else len(self.models)) @@ -204,19 +202,31 @@ class MatchRule: return claimed def strict_matched( - self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, ) -> bool: """Return True if this device matches the criteria.""" - return all(self._matched(manufacturer, model, cluster_handlers, quirk_class)) + return all(self._matched(manufacturer, model, cluster_handlers, quirk_id)) def loose_matched( - self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, ) -> bool: """Return True if this device matches the criteria.""" - return any(self._matched(manufacturer, model, cluster_handlers, quirk_class)) + return any(self._matched(manufacturer, model, cluster_handlers, quirk_id)) def _matched( - self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, ) -> list: """Return a list of field matches.""" if not any(attr.asdict(self).values()): @@ -243,14 +253,11 @@ class MatchRule: else: matches.append(model in self.models) - if self.quirk_classes: - if callable(self.quirk_classes): - matches.append(self.quirk_classes(quirk_class)) + if self.quirk_ids and quirk_id: + if callable(self.quirk_ids): + matches.append(self.quirk_ids(quirk_id)) else: - matches.append( - quirk_class.split(".")[-2:] - in [x.split(".")[-2:] for x in self.quirk_classes] - ) + matches.append(quirk_id in self.quirk_ids) return matches @@ -292,13 +299,13 @@ class ZHAEntityRegistry: manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], - quirk_class: str, + quirk_id: str | None, default: type[ZhaEntity] | None = None, ) -> 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=WEIGHT_ATTR, reverse=True): - if match.strict_matched(manufacturer, model, cluster_handlers, quirk_class): + if match.strict_matched(manufacturer, model, cluster_handlers, quirk_id): claimed = match.claim_cluster_handlers(cluster_handlers) return self._strict_registry[component][match], claimed @@ -309,7 +316,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], - quirk_class: str, + quirk_id: str | None, ) -> tuple[ dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] ]: @@ -323,7 +330,7 @@ class ZHAEntityRegistry: sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( - manufacturer, model, cluster_handlers, quirk_class + manufacturer, model, cluster_handlers, quirk_id ): claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: @@ -342,7 +349,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], - quirk_class: str, + quirk_id: str | None, ) -> tuple[ dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] ]: @@ -359,7 +366,7 @@ class ZHAEntityRegistry: sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( - manufacturer, model, cluster_handlers, quirk_class + manufacturer, model, cluster_handlers, quirk_id ): claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: @@ -385,7 +392,7 @@ class ZHAEntityRegistry: manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, aux_cluster_handlers: Callable | set[str] | str | None = None, - quirk_classes: set[str] | str | None = None, + quirk_ids: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" @@ -395,7 +402,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_cluster_handlers, - quirk_classes, + quirk_ids, ) def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: @@ -417,7 +424,7 @@ class ZHAEntityRegistry: models: 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, + quirk_ids: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -427,7 +434,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_cluster_handlers, - quirk_classes, + quirk_ids, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: @@ -452,7 +459,7 @@ class ZHAEntityRegistry: models: 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, + quirk_ids: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -462,7 +469,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_cluster_handlers, - quirk_classes, + quirk_ids, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index f2aed0390f3..f36cbc13533 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -11,6 +11,7 @@ from zigpy.zcl.foundation import Status from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, ) @@ -73,13 +74,14 @@ async def async_setup_entry( class ZhaCover(ZhaEntity, CoverEntity): """Representation of a ZHA cover.""" - _attr_name: str = "Cover" + _attr_translation_key: str = "cover" def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cover_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) self._current_position = None + self._tilt_position = None async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" @@ -94,6 +96,10 @@ class ZhaCover(ZhaEntity, CoverEntity): self._state = last_state.state if "current_position" in last_state.attributes: self._current_position = last_state.attributes["current_position"] + if "current_tilt_position" in last_state.attributes: + self._tilt_position = last_state.attributes[ + "current_tilt_position" + ] # first allocation activate tilt @property def is_closed(self) -> bool | None: @@ -120,11 +126,20 @@ class ZhaCover(ZhaEntity, CoverEntity): """ return self._current_position + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt position of the cover.""" + return self._tilt_position + @callback def async_set_position(self, attr_id, attr_name, value): """Handle position update from cluster handler.""" - _LOGGER.debug("setting position: %s", value) - self._current_position = 100 - value + _LOGGER.debug("setting position: %s %s %s", attr_id, attr_name, value) + if attr_name == "current_position_lift_percentage": + self._current_position = 100 - value + elif attr_name == "current_position_tilt_percentage": + self._tilt_position = 100 - value + if self._current_position == 0: self._state = STATE_CLOSED elif self._current_position == 100: @@ -145,6 +160,13 @@ class ZhaCover(ZhaEntity, CoverEntity): raise HomeAssistantError(f"Failed to open cover: {res[1]}") self.async_update_state(STATE_OPENING) + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + res = await self._cover_cluster_handler.go_to_tilt_percentage(0) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to open cover tilt: {res[1]}") + self.async_update_state(STATE_OPENING) + async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" res = await self._cover_cluster_handler.down_close() @@ -152,6 +174,13 @@ class ZhaCover(ZhaEntity, CoverEntity): raise HomeAssistantError(f"Failed to close cover: {res[1]}") self.async_update_state(STATE_CLOSING) + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + res = await self._cover_cluster_handler.go_to_tilt_percentage(100) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to close cover tilt: {res[1]}") + self.async_update_state(STATE_CLOSING) + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] @@ -162,6 +191,16 @@ class ZhaCover(ZhaEntity, CoverEntity): STATE_CLOSING if new_pos < self._current_position else STATE_OPENING ) + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover til to a specific position.""" + new_pos = kwargs[ATTR_TILT_POSITION] + res = await self._cover_cluster_handler.go_to_tilt_percentage(100 - new_pos) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to set cover tilt position: {res[1]}") + self.async_update_state( + STATE_CLOSING if new_pos < self._tilt_position else STATE_OPENING + ) + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the window cover.""" res = await self._cover_cluster_handler.stop() @@ -170,28 +209,9 @@ class ZhaCover(ZhaEntity, CoverEntity): self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED self.async_write_ha_state() - async def async_update(self) -> None: - """Attempt to retrieve the open/close state of the cover.""" - await super().async_update() - await self.async_get_state() - - async def async_get_state(self, from_cache=True): - """Fetch the current state.""" - _LOGGER.debug("polling current state") - 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) - - if pos is not None: - self._current_position = 100 - pos - self._state = ( - STATE_OPEN if self.current_cover_position > 0 else STATE_CLOSED - ) - else: - self._current_position = None - self._state = None + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + await self.async_stop_cover() @MULTI_MATCH( @@ -205,7 +225,7 @@ class Shade(ZhaEntity, CoverEntity): """ZHA Shade.""" _attr_device_class = CoverDeviceClass.SHADE - _attr_name: str = "Shade" + _attr_translation_key: str = "shade" def __init__( self, @@ -313,9 +333,8 @@ class Shade(ZhaEntity, CoverEntity): class KeenVent(Shade): """Keen vent cover.""" - _attr_name: str = "Keen vent" - _attr_device_class = CoverDeviceClass.DAMPER + _attr_translation_key: str = "keen_vent" async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index da34b829907..05e1da7c570 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -46,15 +46,18 @@ DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 class BaseZhaEntity(LogMixin, entity.Entity): """A base class for ZHA entities.""" - unique_id_suffix: str | None = None + _unique_id_suffix: str | None = None + """suffix to add to the unique_id of the entity. Used for multi + entities using the same cluster handler/cluster id for the entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__(self, unique_id: str, zha_device: ZHADevice, **kwargs: Any) -> None: """Init ZHA entity.""" self._unique_id: str = unique_id - if self.unique_id_suffix: - self._unique_id += f"-{self.unique_id_suffix}" + if self._unique_id_suffix: + self._unique_id += f"-{self._unique_id_suffix}" self._state: Any = None self._extra_state_attributes: dict[str, Any] = {} self._zha_device = zha_device @@ -144,16 +147,6 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): remove_future: asyncio.Future[Any] - def __init_subclass__(cls, id_suffix: str | None = None, **kwargs: Any) -> None: - """Initialize subclass. - - :param id_suffix: suffix to add to the unique_id of the entity. Used for multi - entities using the same cluster handler/cluster id for the entity. - """ - super().__init_subclass__(**kwargs) - if id_suffix: - cls.unique_id_suffix = id_suffix - def __init__( self, unique_id: str, diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 73b128db109..05bf3469c7b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -45,9 +45,6 @@ PRESET_MODE_SMART = "smart" SPEED_RANGE = (1, 3) # off is not included PRESET_MODES_TO_NAME = {4: PRESET_MODE_ON, 5: PRESET_MODE_AUTO, 6: PRESET_MODE_SMART} -NAME_TO_PRESET_MODE = {v: k for k, v in PRESET_MODES_TO_NAME.items()} -PRESET_MODES = list(NAME_TO_PRESET_MODE) - DEFAULT_ON_PERCENTAGE = 50 STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.FAN) @@ -80,16 +77,37 @@ class BaseFan(FanEntity): """Base representation of a ZHA fan.""" _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_translation_key: str = "fan" @property def preset_modes(self) -> list[str]: """Return the available preset modes.""" - return PRESET_MODES + return list(self.preset_modes_to_name.values()) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return PRESET_MODES_TO_NAME + + @property + def preset_name_to_mode(self) -> dict[str, int]: + """Return a dict from preset name to mode.""" + return {v: k for k, v in self.preset_modes_to_name.items()} + + @property + def default_on_percentage(self) -> int: + """Return the default on percentage.""" + return DEFAULT_ON_PERCENTAGE + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return SPEED_RANGE @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE) + return int_states_in_range(self.speed_range) async def async_turn_on( self, @@ -99,7 +117,7 @@ class BaseFan(FanEntity): ) -> None: """Turn the entity on.""" if percentage is None: - percentage = DEFAULT_ON_PERCENTAGE + percentage = self.default_on_percentage await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: @@ -108,7 +126,7 @@ class BaseFan(FanEntity): async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" - fan_mode = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + fan_mode = math.ceil(percentage_to_ranged_value(self.speed_range, percentage)) await self._async_set_fan_mode(fan_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -118,7 +136,7 @@ class BaseFan(FanEntity): f"The preset_mode {preset_mode} is not a valid preset_mode:" f" {self.preset_modes}" ) - await self._async_set_fan_mode(NAME_TO_PRESET_MODE[preset_mode]) + await self._async_set_fan_mode(self.preset_name_to_mode[preset_mode]) @abstractmethod async def _async_set_fan_mode(self, fan_mode: int) -> None: @@ -133,8 +151,6 @@ class BaseFan(FanEntity): class ZhaFan(BaseFan, ZhaEntity): """Representation of a ZHA fan.""" - _attr_name: str = "Fan" - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @@ -152,19 +168,19 @@ class ZhaFan(BaseFan, ZhaEntity): """Return the current speed percentage.""" if ( self._fan_cluster_handler.fan_mode is None - or self._fan_cluster_handler.fan_mode > SPEED_RANGE[1] + or self._fan_cluster_handler.fan_mode > self.speed_range[1] ): return None if self._fan_cluster_handler.fan_mode == 0: return 0 return ranged_value_to_percentage( - SPEED_RANGE, self._fan_cluster_handler.fan_mode + self.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_cluster_handler.fan_mode) + return self.preset_modes_to_name.get(self._fan_cluster_handler.fan_mode) @callback def async_set_state(self, attr_id, attr_name, value): @@ -181,6 +197,8 @@ class ZhaFan(BaseFan, ZhaEntity): class FanGroup(BaseFan, ZhaGroupEntity): """Representation of a fan group.""" + _attr_translation_key: str = "fan_group" + def __init__( self, entity_ids: list[str], unique_id: str, group_id: int, zha_device, **kwargs ) -> None: @@ -251,97 +269,53 @@ IKEA_PRESET_MODES_TO_NAME = { 9: "Speed 4.5", 10: "Speed 5", } -IKEA_NAME_TO_PRESET_MODE = {v: k for k, v in IKEA_PRESET_MODES_TO_NAME.items()} -IKEA_PRESET_MODES = list(IKEA_NAME_TO_PRESET_MODE) @MULTI_MATCH( cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) -class IkeaFan(BaseFan, ZhaEntity): - """Representation of a ZHA fan.""" +class IkeaFan(ZhaFan): + """Representation of an Ikea fan.""" - _attr_name: str = "IKEA fan" - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: """Init this sensor.""" 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_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return IKEA_PRESET_MODES_TO_NAME + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return IKEA_SPEED_RANGE + + @property + def default_on_percentage(self) -> int: + """Return the default on percentage.""" + return int( + (100 / self.speed_count) * self.preset_name_to_mode[PRESET_MODE_AUTO] ) - @property - def preset_modes(self) -> list[str]: - """Return the available preset modes.""" - return IKEA_PRESET_MODES + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_FAN, + models={"HBUniversalCFRemote", "HDC52EastwindFan"}, +) +class KofFan(ZhaFan): + """Representation of a fan made by King Of Fans.""" + + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(IKEA_SPEED_RANGE) - - async def async_set_percentage(self, percentage: int) -> None: - """Set the speed percentage of the fan.""" - fan_mode = math.ceil(percentage_to_ranged_value(IKEA_SPEED_RANGE, percentage)) - await self._async_set_fan_mode(fan_mode) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode for the fan.""" - if preset_mode not in self.preset_modes: - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) - await self._async_set_fan_mode(IKEA_NAME_TO_PRESET_MODE[preset_mode]) + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return (1, 4) @property - def percentage(self) -> int | None: - """Return the current speed percentage.""" - if ( - self._fan_cluster_handler.fan_mode is None - or self._fan_cluster_handler.fan_mode > IKEA_SPEED_RANGE[1] - ): - return None - if self._fan_cluster_handler.fan_mode == 0: - return 0 - 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_cluster_handler.fan_mode) - - async def async_turn_on( - self, - percentage: int | None = None, - preset_mode: str | None = None, - **kwargs: Any, - ) -> None: - """Turn the entity on.""" - if percentage is None: - percentage = int( - (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[PRESET_MODE_AUTO] - ) - await self.async_set_percentage(percentage) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - await self.async_set_percentage(0) - - @callback - def async_set_state(self, attr_id, attr_name, value): - """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_cluster_handler.async_set_speed(fan_mode) - self.async_set_state(0, "fan_mode", fan_mode) + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return {6: PRESET_MODE_SMART} diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 967d0fc9134..6a01d550466 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -637,8 +637,8 @@ class BaseLight(LogMixin, light.LightEntity): class Light(BaseLight, ZhaEntity): """Representation of a ZHA or ZLL light.""" - _attr_name: str = "Light" _attr_supported_color_modes: set[ColorMode] + _attr_translation_key: str = "light" _REFRESH_INTERVAL = (45, 75) def __init__( @@ -850,8 +850,8 @@ class Light(BaseLight, ZhaEntity): self._off_with_transition = last_state.attributes["off_with_transition"] if "off_brightness" in last_state.attributes: self._off_brightness = last_state.attributes["off_brightness"] - if "color_mode" in last_state.attributes: - self._attr_color_mode = ColorMode(last_state.attributes["color_mode"]) + if (color_mode := last_state.attributes.get("color_mode")) is not None: + self._attr_color_mode = ColorMode(color_mode) if "color_temp" in last_state.attributes: self._attr_color_temp = last_state.attributes["color_temp"] if "xy_color" in last_state.attributes: @@ -1066,7 +1066,6 @@ class Light(BaseLight, ZhaEntity): class HueLight(Light): """Representation of a HUE light which does not report attributes.""" - _attr_name: str = "Light" _REFRESH_INTERVAL = (3, 5) @@ -1078,7 +1077,6 @@ class HueLight(Light): class ForceOnLight(Light): """Representation of a light which does not respect on/off for move_to_level_with_on_off commands.""" - _attr_name: str = "Light" _FORCE_ON = True @@ -1090,8 +1088,6 @@ class ForceOnLight(Light): class MinTransitionLight(Light): """Representation of a light which does not react to any "move to" calls with 0 as a transition.""" - _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 @@ -1100,6 +1096,8 @@ class MinTransitionLight(Light): class LightGroup(BaseLight, ZhaGroupEntity): """Representation of a light group.""" + _attr_translation_key: str = "light_group" + def __init__( self, entity_ids: list[str], diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 9bac9a59a38..ccfb5434154 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -97,7 +97,7 @@ async def async_setup_entry( class ZhaDoorLock(ZhaEntity, LockEntity): """Representation of a ZHA lock.""" - _attr_name: str = "Door lock" + _attr_translation_key: str = "door_lock" def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 88864974eba..6efee0e96ac 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,13 +21,13 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.5", + "bellows==0.36.8", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.105", + "zha-quirks==0.0.106", "zigpy-deconz==0.21.1", - "zigpy==0.57.2", - "zigpy-xbee==0.18.3", + "zigpy==0.59.0", + "zigpy-xbee==0.19.0", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.6", "universal-silabs-flasher==0.0.14", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index b6876155312..ae2f9e0b758 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -278,7 +278,7 @@ async def async_setup_entry( class ZhaNumber(ZhaEntity, NumberEntity): """Representation of a ZHA Number entity.""" - _attr_name: str = "Number" + _attr_translation_key: str = "number" def __init__( self, @@ -381,7 +381,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG _attr_native_step: float = 1.0 _attr_multiplier: float = 1 - _zcl_attribute: str + _attribute_name: str @classmethod def create_entity( @@ -397,13 +397,13 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): """ cluster_handler = cluster_handlers[0] if ( - cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes - or cls._zcl_attribute not in cluster_handler.cluster.attributes_by_name - or cluster_handler.cluster.get(cls._zcl_attribute) is None + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", - cls._zcl_attribute, + cls._attribute_name, cls.__name__, ) return None @@ -425,14 +425,14 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): def native_value(self) -> float: """Return the current value.""" return ( - self._cluster_handler.cluster.get(self._zcl_attribute) + self._cluster_handler.cluster.get(self._attribute_name) * self._attr_multiplier ) async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" await self._cluster_handler.write_attributes_safe( - {self._zcl_attribute: int(value / self._attr_multiplier)} + {self._attribute_name: int(value / self._attr_multiplier)} ) self.async_write_ha_state() @@ -442,7 +442,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): _LOGGER.debug("polling current state") if self._cluster_handler: value = await self._cluster_handler.get_attribute_value( - self._zcl_attribute, from_cache=False + self._attribute_name, from_cache=False ) _LOGGER.debug("read value=%s", value) @@ -452,104 +452,98 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): models={"lumi.motion.ac02", "lumi.motion.agl04"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraMotionDetectionInterval( - ZHANumberConfigurationEntity, id_suffix="detection_interval" -): +class AqaraMotionDetectionInterval(ZHANumberConfigurationEntity): """Representation of a ZHA motion detection interval configuration entity.""" + _unique_id_suffix = "detection_interval" _attr_native_min_value: float = 2 _attr_native_max_value: float = 65535 - _zcl_attribute: str = "detection_interval" - _attr_name = "Detection interval" + _attribute_name = "detection_interval" + _attr_translation_key: str = "detection_interval" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class OnOffTransitionTimeConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="on_off_transition_time" -): +class OnOffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA on off transition time configuration entity.""" + _unique_id_suffix = "on_off_transition_time" _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFF - _zcl_attribute: str = "on_off_transition_time" - _attr_name = "On/Off transition time" + _attribute_name = "on_off_transition_time" + _attr_translation_key: str = "on_off_transition_time" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_level"): +class OnLevelConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA on level configuration entity.""" + _unique_id_suffix = "on_level" _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF - _zcl_attribute: str = "on_level" - _attr_name = "On level" + _attribute_name = "on_level" + _attr_translation_key: str = "on_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class OnTransitionTimeConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="on_transition_time" -): +class OnTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA on transition time configuration entity.""" + _unique_id_suffix = "on_transition_time" _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE - _zcl_attribute: str = "on_transition_time" - _attr_name = "On transition time" + _attribute_name = "on_transition_time" + _attr_translation_key: str = "on_transition_time" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class OffTransitionTimeConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="off_transition_time" -): +class OffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA off transition time configuration entity.""" + _unique_id_suffix = "off_transition_time" _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE - _zcl_attribute: str = "off_transition_time" - _attr_name = "Off transition time" + _attribute_name = "off_transition_time" + _attr_translation_key: str = "off_transition_time" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class DefaultMoveRateConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="default_move_rate" -): +class DefaultMoveRateConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA default move rate configuration entity.""" + _unique_id_suffix = "default_move_rate" _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFE - _zcl_attribute: str = "default_move_rate" - _attr_name = "Default move rate" + _attribute_name = "default_move_rate" + _attr_translation_key: str = "default_move_rate" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class StartUpCurrentLevelConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="start_up_current_level" -): +class StartUpCurrentLevelConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA startup current level configuration entity.""" + _unique_id_suffix = "start_up_current_level" _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF - _zcl_attribute: str = "start_up_current_level" - _attr_name = "Start-up current level" + _attribute_name = "start_up_current_level" + _attr_translation_key: str = "start_up_current_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class StartUpColorTemperatureConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="start_up_color_temperature" -): +class StartUpColorTemperatureConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA startup color temperature configuration entity.""" + _unique_id_suffix = "start_up_color_temperature" _attr_native_min_value: float = 153 _attr_native_max_value: float = 500 - _zcl_attribute: str = "start_up_color_temperature" - _attr_name = "Start-up color temperature" + _attribute_name = "start_up_color_temperature" + _attr_translation_key: str = "start_up_color_temperature" def __init__( self, @@ -572,30 +566,32 @@ class StartUpColorTemperatureConfigurationEntity( }, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_duration"): +class TimerDurationMinutes(ZHANumberConfigurationEntity): """Representation of a ZHA timer duration configuration entity.""" + _unique_id_suffix = "timer_duration" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0x257 _attr_native_unit_of_measurement: str | None = UNITS[72] - _zcl_attribute: str = "timer_duration" - _attr_name = "Timer duration" + _attribute_name = "timer_duration" + _attr_translation_key: str = "timer_duration" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier") # pylint: disable-next=hass-invalid-inheritance # needs fixing -class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): +class FilterLifeTime(ZHANumberConfigurationEntity): """Representation of a ZHA filter lifetime configuration entity.""" + _unique_id_suffix = "filter_life_time" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFFFFFFFF _attr_native_unit_of_measurement: str | None = UNITS[72] - _zcl_attribute: str = "filter_life_time" - _attr_name = "Filter life time" + _attribute_name = "filter_life_time" + _attr_translation_key: str = "filter_life_time" @CONFIG_DIAGNOSTIC_MATCH( @@ -604,310 +600,296 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time") models={"ti.router"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_power"): +class TiRouterTransmitPower(ZHANumberConfigurationEntity): """Representation of a ZHA TI transmit power configuration entity.""" + _unique_id_suffix = "transmit_power" _attr_native_min_value: float = -20 _attr_native_max_value: float = 20 - _zcl_attribute: str = "transmit_power" - _attr_name = "Transmit power" + _attribute_name = "transmit_power" + _attr_translation_key: str = "transmit_power" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingUpSpeed( - ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_remote" -): +class InovelliRemoteDimmingUpSpeed(ZHANumberConfigurationEntity): """Inovelli remote dimming up speed configuration entity.""" + _unique_id_suffix = "dimming_speed_up_remote" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 126 - _zcl_attribute: str = "dimming_speed_up_remote" - _attr_name: str = "Remote dimming up speed" + _attribute_name = "dimming_speed_up_remote" + _attr_translation_key: str = "dimming_speed_up_remote" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay"): +class InovelliButtonDelay(ZHANumberConfigurationEntity): """Inovelli button delay configuration entity.""" + _unique_id_suffix = "dimming_speed_up_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 9 - _zcl_attribute: str = "button_delay" - _attr_name: str = "Button delay" + _attribute_name = "button_delay" + _attr_translation_key: str = "button_delay" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalDimmingUpSpeed( - ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_local" -): +class InovelliLocalDimmingUpSpeed(ZHANumberConfigurationEntity): """Inovelli local dimming up speed configuration entity.""" + _unique_id_suffix = "dimming_speed_up_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "dimming_speed_up_local" - _attr_name: str = "Local dimming up speed" + _attribute_name = "dimming_speed_up_local" + _attr_translation_key: str = "dimming_speed_up_local" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalRampRateOffToOn( - ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_local" -): +class InovelliLocalRampRateOffToOn(ZHANumberConfigurationEntity): """Inovelli off to on local ramp rate configuration entity.""" + _unique_id_suffix = "ramp_rate_off_to_on_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "ramp_rate_off_to_on_local" - _attr_name: str = "Local ramp rate off to on" + _attribute_name = "ramp_rate_off_to_on_local" + _attr_translation_key: str = "ramp_rate_off_to_on_local" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingSpeedOffToOn( - ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_remote" -): +class InovelliRemoteDimmingSpeedOffToOn(ZHANumberConfigurationEntity): """Inovelli off to on remote ramp rate configuration entity.""" + _unique_id_suffix = "ramp_rate_off_to_on_remote" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "ramp_rate_off_to_on_remote" - _attr_name: str = "Remote ramp rate off to on" + _attribute_name = "ramp_rate_off_to_on_remote" + _attr_translation_key: str = "ramp_rate_off_to_on_remote" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingDownSpeed( - ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_remote" -): +class InovelliRemoteDimmingDownSpeed(ZHANumberConfigurationEntity): """Inovelli remote dimming down speed configuration entity.""" + _unique_id_suffix = "dimming_speed_down_remote" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "dimming_speed_down_remote" - _attr_name: str = "Remote dimming down speed" + _attribute_name = "dimming_speed_down_remote" + _attr_translation_key: str = "dimming_speed_down_remote" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalDimmingDownSpeed( - ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_local" -): +class InovelliLocalDimmingDownSpeed(ZHANumberConfigurationEntity): """Inovelli local dimming down speed configuration entity.""" + _unique_id_suffix = "dimming_speed_down_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "dimming_speed_down_local" - _attr_name: str = "Local dimming down speed" + _attribute_name = "dimming_speed_down_local" + _attr_translation_key: str = "dimming_speed_down_local" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalRampRateOnToOff( - ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_local" -): +class InovelliLocalRampRateOnToOff(ZHANumberConfigurationEntity): """Inovelli local on to off ramp rate configuration entity.""" + _unique_id_suffix = "ramp_rate_on_to_off_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "ramp_rate_on_to_off_local" - _attr_name: str = "Local ramp rate on to off" + _attribute_name = "ramp_rate_on_to_off_local" + _attr_translation_key: str = "ramp_rate_on_to_off_local" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingSpeedOnToOff( - ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_remote" -): +class InovelliRemoteDimmingSpeedOnToOff(ZHANumberConfigurationEntity): """Inovelli remote on to off ramp rate configuration entity.""" + _unique_id_suffix = "ramp_rate_on_to_off_remote" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "ramp_rate_on_to_off_remote" - _attr_name: str = "Remote ramp rate on to off" + _attribute_name = "ramp_rate_on_to_off_remote" + _attr_translation_key: str = "ramp_rate_on_to_off_remote" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliMinimumLoadDimmingLevel( - ZHANumberConfigurationEntity, id_suffix="minimum_level" -): +class InovelliMinimumLoadDimmingLevel(ZHANumberConfigurationEntity): """Inovelli minimum load dimming level configuration entity.""" + _unique_id_suffix = "minimum_level" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 1 _attr_native_max_value: float = 254 - _zcl_attribute: str = "minimum_level" - _attr_name: str = "Minimum load dimming level" + _attribute_name = "minimum_level" + _attr_translation_key: str = "minimum_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliMaximumLoadDimmingLevel( - ZHANumberConfigurationEntity, id_suffix="maximum_level" -): +class InovelliMaximumLoadDimmingLevel(ZHANumberConfigurationEntity): """Inovelli maximum load dimming level configuration entity.""" + _unique_id_suffix = "maximum_level" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 2 _attr_native_max_value: float = 255 - _zcl_attribute: str = "maximum_level" - _attr_name: str = "Maximum load dimming level" + _attribute_name = "maximum_level" + _attr_translation_key: str = "maximum_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliAutoShutoffTimer( - ZHANumberConfigurationEntity, id_suffix="auto_off_timer" -): +class InovelliAutoShutoffTimer(ZHANumberConfigurationEntity): """Inovelli automatic switch shutoff timer configuration entity.""" + _unique_id_suffix = "auto_off_timer" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0 _attr_native_max_value: float = 32767 - _zcl_attribute: str = "auto_off_timer" - _attr_name: str = "Automatic switch shutoff timer" + _attribute_name = "auto_off_timer" + _attr_translation_key: str = "auto_off_timer" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLoadLevelIndicatorTimeout( - ZHANumberConfigurationEntity, id_suffix="load_level_indicator_timeout" -): +class InovelliLoadLevelIndicatorTimeout(ZHANumberConfigurationEntity): """Inovelli load level indicator timeout configuration entity.""" + _unique_id_suffix = "load_level_indicator_timeout" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0 _attr_native_max_value: float = 11 - _zcl_attribute: str = "load_level_indicator_timeout" - _attr_name: str = "Load level indicator timeout" + _attribute_name = "load_level_indicator_timeout" + _attr_translation_key: str = "load_level_indicator_timeout" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOnColor( - ZHANumberConfigurationEntity, id_suffix="led_color_when_on" -): +class InovelliDefaultAllLEDOnColor(ZHANumberConfigurationEntity): """Inovelli default all led color when on configuration entity.""" + _unique_id_suffix = "led_color_when_on" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[15] _attr_native_min_value: float = 0 _attr_native_max_value: float = 255 - _zcl_attribute: str = "led_color_when_on" - _attr_name: str = "Default all LED on color" + _attribute_name = "led_color_when_on" + _attr_translation_key: str = "led_color_when_on" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOffColor( - ZHANumberConfigurationEntity, id_suffix="led_color_when_off" -): +class InovelliDefaultAllLEDOffColor(ZHANumberConfigurationEntity): """Inovelli default all led color when off configuration entity.""" + _unique_id_suffix = "led_color_when_off" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[15] _attr_native_min_value: float = 0 _attr_native_max_value: float = 255 - _zcl_attribute: str = "led_color_when_off" - _attr_name: str = "Default all LED off color" + _attribute_name = "led_color_when_off" + _attr_translation_key: str = "led_color_when_off" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOnIntensity( - ZHANumberConfigurationEntity, id_suffix="led_intensity_when_on" -): +class InovelliDefaultAllLEDOnIntensity(ZHANumberConfigurationEntity): """Inovelli default all led intensity when on configuration entity.""" + _unique_id_suffix = "led_intensity_when_on" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 0 _attr_native_max_value: float = 100 - _zcl_attribute: str = "led_intensity_when_on" - _attr_name: str = "Default all LED on intensity" + _attribute_name = "led_intensity_when_on" + _attr_translation_key: str = "led_intensity_when_on" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOffIntensity( - ZHANumberConfigurationEntity, id_suffix="led_intensity_when_off" -): +class InovelliDefaultAllLEDOffIntensity(ZHANumberConfigurationEntity): """Inovelli default all led intensity when off configuration entity.""" + _unique_id_suffix = "led_intensity_when_off" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 0 _attr_native_max_value: float = 100 - _zcl_attribute: str = "led_intensity_when_off" - _attr_name: str = "Default all LED off intensity" + _attribute_name = "led_intensity_when_off" + _attr_translation_key: str = "led_intensity_when_off" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDoubleTapUpLevel( - ZHANumberConfigurationEntity, id_suffix="double_tap_up_level" -): +class InovelliDoubleTapUpLevel(ZHANumberConfigurationEntity): """Inovelli double tap up level configuration entity.""" + _unique_id_suffix = "double_tap_up_level" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 2 _attr_native_max_value: float = 254 - _zcl_attribute: str = "double_tap_up_level" - _attr_name: str = "Double tap up level" + _attribute_name = "double_tap_up_level" + _attr_translation_key: str = "double_tap_up_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDoubleTapDownLevel( - ZHANumberConfigurationEntity, id_suffix="double_tap_down_level" -): +class InovelliDoubleTapDownLevel(ZHANumberConfigurationEntity): """Inovelli double tap down level configuration entity.""" + _unique_id_suffix = "double_tap_down_level" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 0 _attr_native_max_value: float = 254 - _zcl_attribute: str = "double_tap_down_level" - _attr_name: str = "Double tap down level" + _attribute_name = "double_tap_down_level" + _attr_translation_key: str = "double_tap_down_level" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving_size"): +class AqaraPetFeederServingSize(ZHANumberConfigurationEntity): """Aqara pet feeder serving size configuration entity.""" + _unique_id_suffix = "serving_size" _attr_entity_category = EntityCategory.CONFIG _attr_native_min_value: float = 1 _attr_native_max_value: float = 10 - _zcl_attribute: str = "serving_size" - _attr_name: str = "Serving to dispense" + _attribute_name = "serving_size" + _attr_translation_key: str = "serving_size" + _attr_mode: NumberMode = NumberMode.BOX _attr_icon: str = "mdi:counter" @@ -916,16 +898,16 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederPortionWeight( - ZHANumberConfigurationEntity, id_suffix="portion_weight" -): +class AqaraPetFeederPortionWeight(ZHANumberConfigurationEntity): """Aqara pet feeder portion weight configuration entity.""" + _unique_id_suffix = "portion_weight" _attr_entity_category = EntityCategory.CONFIG _attr_native_min_value: float = 1 _attr_native_max_value: float = 100 - _zcl_attribute: str = "portion_weight" - _attr_name: str = "Portion weight" + _attribute_name = "portion_weight" + _attr_translation_key: str = "portion_weight" + _attr_mode: NumberMode = NumberMode.BOX _attr_native_unit_of_measurement: str = UnitOfMass.GRAMS _attr_icon: str = "mdi:weight-gram" @@ -935,17 +917,17 @@ class AqaraPetFeederPortionWeight( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraThermostatAwayTemp( - ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" -): +class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): """Aqara away preset temperature configuration entity.""" + _unique_id_suffix = "away_preset_temperature" _attr_entity_category = EntityCategory.CONFIG _attr_native_min_value: float = 5 _attr_native_max_value: float = 30 _attr_multiplier: float = 0.01 - _zcl_attribute: str = "away_preset_temperature" - _attr_name: str = "Away preset temperature" + _attribute_name = "away_preset_temperature" + _attr_translation_key: str = "away_preset_temperature" + _attr_mode: NumberMode = NumberMode.SLIDER _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index ca030600751..d20cf752a91 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -14,7 +14,12 @@ from bellows.config import CONF_USE_THREAD import voluptuous as vol from zigpy.application import ControllerApplication import zigpy.backups -from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_BACKUP_ENABLED +from zigpy.config import ( + CONF_DATABASE, + CONF_DEVICE, + CONF_DEVICE_PATH, + CONF_NWK_BACKUP_ENABLED, +) from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries @@ -23,7 +28,6 @@ from homeassistant.core import HomeAssistant from . import repairs from .core.const import ( - CONF_DATABASE, CONF_RADIO_TYPE, CONF_ZIGPY, DEFAULT_DATABASE_NAME, @@ -218,8 +222,10 @@ class ZhaRadioManager: repairs.async_delete_blocking_issues(self.hass) return ProbeResult.RADIO_TYPE_DETECTED - with suppress(repairs.AlreadyRunningEZSP): - if await repairs.warn_on_wrong_silabs_firmware(self.hass, self.device_path): + with suppress(repairs.wrong_silabs_firmware.AlreadyRunningEZSP): + if await repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware( + self.hass, self.device_path + ): return ProbeResult.WRONG_FIRMWARE_INSTALLED return ProbeResult.PROBING_FAILED diff --git a/homeassistant/components/zha/repairs/__init__.py b/homeassistant/components/zha/repairs/__init__.py new file mode 100644 index 00000000000..a3c2ea6f292 --- /dev/null +++ b/homeassistant/components/zha/repairs/__init__.py @@ -0,0 +1,33 @@ +"""ZHA repairs for common environmental and device problems.""" +from __future__ import annotations + +from typing import Any, cast + +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from ..core.const import DOMAIN +from .network_settings_inconsistent import ( + ISSUE_INCONSISTENT_NETWORK_SETTINGS, + NetworkSettingsInconsistentFlow, +) +from .wrong_silabs_firmware import ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED + + +def async_delete_blocking_issues(hass: HomeAssistant) -> None: + """Delete repair issues that should disappear on a successful startup.""" + ir.async_delete_issue(hass, DOMAIN, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED) + ir.async_delete_issue(hass, DOMAIN, ISSUE_INCONSISTENT_NETWORK_SETTINGS) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id == ISSUE_INCONSISTENT_NETWORK_SETTINGS: + return NetworkSettingsInconsistentFlow(hass, cast(dict[str, Any], data)) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/zha/repairs/network_settings_inconsistent.py b/homeassistant/components/zha/repairs/network_settings_inconsistent.py new file mode 100644 index 00000000000..0a478f4b36a --- /dev/null +++ b/homeassistant/components/zha/repairs/network_settings_inconsistent.py @@ -0,0 +1,151 @@ +"""ZHA repair for inconsistent network settings.""" +from __future__ import annotations + +import logging +from typing import Any + +from zigpy.backups import NetworkBackup + +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import issue_registry as ir + +from ..core.const import DOMAIN +from ..radio_manager import ZhaRadioManager + +_LOGGER = logging.getLogger(__name__) + +ISSUE_INCONSISTENT_NETWORK_SETTINGS = "inconsistent_network_settings" + + +def _format_settings_diff(old_state: NetworkBackup, new_state: NetworkBackup) -> str: + """Format the difference between two network backups.""" + lines: list[str] = [] + + def _add_difference( + lines: list[str], text: str, old: Any, new: Any, pre: bool = True + ) -> None: + """Add a line to the list if the values are different.""" + wrap = "`" if pre else "" + + if old != new: + lines.append(f"{text}: {wrap}{old}{wrap} \u2192 {wrap}{new}{wrap}") + + _add_difference( + lines, + "Channel", + old=old_state.network_info.channel, + new=new_state.network_info.channel, + pre=False, + ) + _add_difference( + lines, + "Node IEEE", + old=old_state.node_info.ieee, + new=new_state.node_info.ieee, + ) + _add_difference( + lines, + "PAN ID", + old=old_state.network_info.pan_id, + new=new_state.network_info.pan_id, + ) + _add_difference( + lines, + "Extended PAN ID", + old=old_state.network_info.extended_pan_id, + new=new_state.network_info.extended_pan_id, + ) + _add_difference( + lines, + "NWK update ID", + old=old_state.network_info.nwk_update_id, + new=new_state.network_info.nwk_update_id, + pre=False, + ) + _add_difference( + lines, + "TC Link Key", + old=old_state.network_info.tc_link_key.key, + new=new_state.network_info.tc_link_key.key, + ) + _add_difference( + lines, + "Network Key", + old=old_state.network_info.network_key.key, + new=new_state.network_info.network_key.key, + ) + + return "\n".join([f"- {line}" for line in lines]) + + +async def warn_on_inconsistent_network_settings( + hass: HomeAssistant, + config_entry: ConfigEntry, + old_state: NetworkBackup, + new_state: NetworkBackup, +) -> None: + """Create a repair if the network settings are inconsistent with the last backup.""" + + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + data={ + "config_entry_id": config_entry.entry_id, + "old_state": old_state.as_dict(), + "new_state": new_state.as_dict(), + }, + ) + + +class NetworkSettingsInconsistentFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, hass: HomeAssistant, data: dict[str, Any]) -> None: + """Initialize the flow.""" + self.hass = hass + self._old_state = NetworkBackup.from_dict(data["old_state"]) + self._new_state = NetworkBackup.from_dict(data["new_state"]) + + self._entry_id: str = data["config_entry_id"] + + config_entry = self.hass.config_entries.async_get_entry(self._entry_id) + assert config_entry is not None + self._radio_mgr = ZhaRadioManager.from_config_entry(self.hass, config_entry) + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_menu( + step_id="init", + menu_options=["restore_old_settings", "use_new_settings"], + description_placeholders={ + "diff": _format_settings_diff(self._old_state, self._new_state) + }, + ) + + async def async_step_use_new_settings( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Step to use the new settings found on the radio.""" + async with self._radio_mgr.connect_zigpy_app() as app: + app.backups.add_backup(self._new_state) + + await self.hass.config_entries.async_reload(self._entry_id) + return self.async_create_entry(title="", data={}) + + async def async_step_restore_old_settings( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Step to restore the most recent backup.""" + await self._radio_mgr.restore_backup(self._old_state) + + await self.hass.config_entries.async_reload(self._entry_id) + return self.async_create_entry(title="", data={}) diff --git a/homeassistant/components/zha/repairs.py b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py similarity index 93% rename from homeassistant/components/zha/repairs.py rename to homeassistant/components/zha/repairs/wrong_silabs_firmware.py index ac523f37aa0..93c5489eda7 100644 --- a/homeassistant/components/zha/repairs.py +++ b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir -from .core.const import DOMAIN +from ..core.const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -119,8 +119,3 @@ async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> boo ) return True - - -def async_delete_blocking_issues(hass: HomeAssistant) -> None: - """Delete repair issues that should disappear on a successful startup.""" - ir.async_delete_issue(hass, DOMAIN, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index fa2e124fd05..46089dd5a28 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -67,7 +67,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): """Representation of a ZHA select entity.""" _attr_entity_category = EntityCategory.CONFIG - _attribute: str + _attribute_name: str _enum: type[Enum] def __init__( @@ -78,7 +78,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): **kwargs: Any, ) -> None: """Init this select entity.""" - self._attribute = self._enum.__name__ + self._attribute_name = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] self._cluster_handler: ClusterHandler = cluster_handlers[0] super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @@ -86,14 +86,14 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - option = self._cluster_handler.data_cache.get(self._attribute) + option = self._cluster_handler.data_cache.get(self._attribute_name) if option is None: return None return option.name.replace("_", " ") async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._cluster_handler.data_cache[self._attribute] = self._enum[ + self._cluster_handler.data_cache[self._attribute_name] = self._enum[ option.replace(" ", "_") ] self.async_write_ha_state() @@ -102,7 +102,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): def async_restore_last_state(self, last_state) -> None: """Restore previous state.""" if last_state.state and last_state.state != STATE_UNKNOWN: - self._cluster_handler.data_cache[self._attribute] = self._enum[ + self._cluster_handler.data_cache[self._attribute_name] = self._enum[ last_state.state.replace(" ", "_") ] @@ -117,47 +117,45 @@ class ZHANonZCLSelectEntity(ZHAEnumSelectEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultToneSelectEntity( - ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.WarningMode.__name__ -): +class ZHADefaultToneSelectEntity(ZHANonZCLSelectEntity): """Representation of a ZHA default siren tone select entity.""" + _unique_id_suffix = IasWd.Warning.WarningMode.__name__ _enum = IasWd.Warning.WarningMode - _attr_name = "Default siren tone" + _attr_translation_key: str = "default_siren_tone" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultSirenLevelSelectEntity( - ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.SirenLevel.__name__ -): +class ZHADefaultSirenLevelSelectEntity(ZHANonZCLSelectEntity): """Representation of a ZHA default siren level select entity.""" + _unique_id_suffix = IasWd.Warning.SirenLevel.__name__ _enum = IasWd.Warning.SirenLevel - _attr_name = "Default siren level" + _attr_translation_key: str = "default_siren_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultStrobeLevelSelectEntity( - ZHANonZCLSelectEntity, id_suffix=IasWd.StrobeLevel.__name__ -): +class ZHADefaultStrobeLevelSelectEntity(ZHANonZCLSelectEntity): """Representation of a ZHA default siren strobe level select entity.""" + _unique_id_suffix = IasWd.StrobeLevel.__name__ _enum = IasWd.StrobeLevel - _attr_name = "Default strobe level" + _attr_translation_key: str = "default_strobe_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__name__): +class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity): """Representation of a ZHA default siren strobe select entity.""" + _unique_id_suffix = Strobe.__name__ _enum = Strobe - _attr_name = "Default strobe" + _attr_translation_key: str = "default_strobe" class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): """Representation of a ZHA ZCL enum select entity.""" - _select_attr: str + _attribute_name: str _attr_entity_category = EntityCategory.CONFIG _enum: type[Enum] @@ -175,13 +173,13 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): """ cluster_handler = cluster_handlers[0] if ( - cls._select_attr in cluster_handler.cluster.unsupported_attributes - or cls._select_attr not in cluster_handler.cluster.attributes_by_name - or cluster_handler.cluster.get(cls._select_attr) is None + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", - cls._select_attr, + cls._attribute_name, cls.__name__, ) return None @@ -203,7 +201,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - option = self._cluster_handler.cluster.get(self._select_attr) + option = self._cluster_handler.cluster.get(self._attribute_name) if option is None: return None option = self._enum(option) @@ -212,7 +210,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self._cluster_handler.write_attributes_safe( - {self._select_attr: self._enum[option.replace(" ", "_")]} + {self._attribute_name: self._enum[option.replace(" ", "_")]} ) self.async_write_ha_state() @@ -230,14 +228,13 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) -class ZHAStartupOnOffSelectEntity( - ZCLEnumSelectEntity, id_suffix=OnOff.StartUpOnOff.__name__ -): +class ZHAStartupOnOffSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA startup onoff select entity.""" - _select_attr = "start_up_on_off" + _unique_id_suffix = OnOff.StartUpOnOff.__name__ + _attribute_name = "start_up_on_off" _enum = OnOff.StartUpOnOff - _attr_name = "Start-up behavior" + _attr_translation_key: str = "start_up_on_off" class TuyaPowerOnState(types.enum8): @@ -273,12 +270,13 @@ class TuyaPowerOnState(types.enum8): "_TZE200_9mahtqtg", }, ) -class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity, id_suffix="power_on_state"): +class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA power on state select entity.""" - _select_attr = "power_on_state" + _unique_id_suffix = "power_on_state" + _attribute_name = "power_on_state" _enum = TuyaPowerOnState - _attr_name = "Power on state" + _attr_translation_key: str = "power_on_state" class TuyaBacklightMode(types.enum8): @@ -293,12 +291,13 @@ class TuyaBacklightMode(types.enum8): cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, ) -class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity, id_suffix="backlight_mode"): +class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA backlight mode select entity.""" - _select_attr = "backlight_mode" + _unique_id_suffix = "backlight_mode" + _attribute_name = "backlight_mode" _enum = TuyaBacklightMode - _attr_name = "Backlight mode" + _attr_translation_key: str = "backlight_mode" class MoesBacklightMode(types.enum8): @@ -331,12 +330,13 @@ class MoesBacklightMode(types.enum8): "_TZE200_9mahtqtg", }, ) -class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity, id_suffix="backlight_mode"): +class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity): """Moes devices have a different backlight mode select options.""" - _select_attr = "backlight_mode" + _unique_id_suffix = "backlight_mode" + _attribute_name = "backlight_mode" _enum = MoesBacklightMode - _attr_name = "Backlight mode" + _attr_translation_key: str = "backlight_mode" class AqaraMotionSensitivities(types.enum8): @@ -351,12 +351,13 @@ class AqaraMotionSensitivities(types.enum8): cluster_handler_names="opple_cluster", models={"lumi.motion.ac01", "lumi.motion.ac02", "lumi.motion.agl04"}, ) -class AqaraMotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): +class AqaraMotionSensitivity(ZCLEnumSelectEntity): """Representation of a ZHA motion sensitivity configuration entity.""" - _select_attr = "motion_sensitivity" + _unique_id_suffix = "motion_sensitivity" + _attribute_name = "motion_sensitivity" _enum = AqaraMotionSensitivities - _attr_name = "Motion sensitivity" + _attr_translation_key: str = "motion_sensitivity" class HueV1MotionSensitivities(types.enum8): @@ -372,12 +373,13 @@ class HueV1MotionSensitivities(types.enum8): manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001"}, ) -class HueV1MotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): +class HueV1MotionSensitivity(ZCLEnumSelectEntity): """Representation of a ZHA motion sensitivity configuration entity.""" - _select_attr = "sensitivity" - _attr_name = "Hue motion sensitivity" + _unique_id_suffix = "motion_sensitivity" + _attribute_name = "sensitivity" _enum = HueV1MotionSensitivities + _attr_translation_key: str = "motion_sensitivity" class HueV2MotionSensitivities(types.enum8): @@ -395,12 +397,13 @@ class HueV2MotionSensitivities(types.enum8): manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML002", "SML003", "SML004"}, ) -class HueV2MotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): +class HueV2MotionSensitivity(ZCLEnumSelectEntity): """Representation of a ZHA motion sensitivity configuration entity.""" - _select_attr = "sensitivity" - _attr_name = "Hue motion sensitivity" + _unique_id_suffix = "motion_sensitivity" + _attribute_name = "sensitivity" _enum = HueV2MotionSensitivities + _attr_translation_key: str = "motion_sensitivity" class AqaraMonitoringModess(types.enum8): @@ -413,12 +416,13 @@ class AqaraMonitoringModess(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} ) -class AqaraMonitoringMode(ZCLEnumSelectEntity, id_suffix="monitoring_mode"): +class AqaraMonitoringMode(ZCLEnumSelectEntity): """Representation of a ZHA monitoring mode configuration entity.""" - _select_attr = "monitoring_mode" + _unique_id_suffix = "monitoring_mode" + _attribute_name = "monitoring_mode" _enum = AqaraMonitoringModess - _attr_name = "Monitoring mode" + _attr_translation_key: str = "monitoring_mode" class AqaraApproachDistances(types.enum8): @@ -432,12 +436,13 @@ class AqaraApproachDistances(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} ) -class AqaraApproachDistance(ZCLEnumSelectEntity, id_suffix="approach_distance"): +class AqaraApproachDistance(ZCLEnumSelectEntity): """Representation of a ZHA approach distance configuration entity.""" - _select_attr = "approach_distance" + _unique_id_suffix = "approach_distance" + _attribute_name = "approach_distance" _enum = AqaraApproachDistances - _attr_name = "Approach distance" + _attr_translation_key: str = "approach_distance" class AqaraE1ReverseDirection(types.enum8): @@ -450,12 +455,13 @@ class AqaraE1ReverseDirection(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="window_covering", models={"lumi.curtain.agl001"} ) -class AqaraCurtainMode(ZCLEnumSelectEntity, id_suffix="window_covering_mode"): +class AqaraCurtainMode(ZCLEnumSelectEntity): """Representation of a ZHA curtain mode configuration entity.""" - _select_attr = "window_covering_mode" + _unique_id_suffix = "window_covering_mode" + _attribute_name = "window_covering_mode" _enum = AqaraE1ReverseDirection - _attr_name = "Curtain mode" + _attr_translation_key: str = "window_covering_mode" class InovelliOutputMode(types.enum1): @@ -468,12 +474,13 @@ class InovelliOutputMode(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliOutputModeEntity(ZCLEnumSelectEntity, id_suffix="output_mode"): +class InovelliOutputModeEntity(ZCLEnumSelectEntity): """Inovelli output mode control.""" - _select_attr = "output_mode" + _unique_id_suffix = "output_mode" + _attribute_name = "output_mode" _enum = InovelliOutputMode - _attr_name: str = "Output mode" + _attr_translation_key: str = "output_mode" class InovelliSwitchType(types.enum8): @@ -488,12 +495,13 @@ class InovelliSwitchType(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliSwitchTypeEntity(ZCLEnumSelectEntity, id_suffix="switch_type"): +class InovelliSwitchTypeEntity(ZCLEnumSelectEntity): """Inovelli switch type control.""" - _select_attr = "switch_type" + _unique_id_suffix = "switch_type" + _attribute_name = "switch_type" _enum = InovelliSwitchType - _attr_name: str = "Switch type" + _attr_translation_key: str = "switch_type" class InovelliLedScalingMode(types.enum1): @@ -506,12 +514,13 @@ class InovelliLedScalingMode(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliLedScalingModeEntity(ZCLEnumSelectEntity, id_suffix="led_scaling_mode"): +class InovelliLedScalingModeEntity(ZCLEnumSelectEntity): """Inovelli led mode control.""" - _select_attr = "led_scaling_mode" + _unique_id_suffix = "led_scaling_mode" + _attribute_name = "led_scaling_mode" _enum = InovelliLedScalingMode - _attr_name: str = "Led scaling mode" + _attr_translation_key: str = "led_scaling_mode" class InovelliNonNeutralOutput(types.enum1): @@ -524,14 +533,13 @@ class InovelliNonNeutralOutput(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliNonNeutralOutputEntity( - ZCLEnumSelectEntity, id_suffix="increased_non_neutral_output" -): +class InovelliNonNeutralOutputEntity(ZCLEnumSelectEntity): """Inovelli non neutral output control.""" - _select_attr = "increased_non_neutral_output" + _unique_id_suffix = "increased_non_neutral_output" + _attribute_name = "increased_non_neutral_output" _enum = InovelliNonNeutralOutput - _attr_name: str = "Non neutral output" + _attr_translation_key: str = "increased_non_neutral_output" class AqaraFeedingMode(types.enum8): @@ -544,12 +552,13 @@ class AqaraFeedingMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -class AqaraPetFeederMode(ZCLEnumSelectEntity, id_suffix="feeding_mode"): +class AqaraPetFeederMode(ZCLEnumSelectEntity): """Representation of an Aqara pet feeder mode configuration entity.""" - _select_attr = "feeding_mode" + _unique_id_suffix = "feeding_mode" + _attribute_name = "feeding_mode" _enum = AqaraFeedingMode - _attr_name = "Mode" + _attr_translation_key: str = "feeding_mode" _attr_icon: str = "mdi:wrench-clock" @@ -564,9 +573,10 @@ class AqaraThermostatPresetMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatPreset(ZCLEnumSelectEntity, id_suffix="preset"): +class AqaraThermostatPreset(ZCLEnumSelectEntity): """Representation of an Aqara thermostat preset configuration entity.""" - _select_attr = "preset" + _unique_id_suffix = "preset" + _attribute_name = "preset" _enum = AqaraThermostatPresetMode - _attr_name = "Preset" + _attr_translation_key: str = "preset" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 1e166675b5b..4fe96109c46 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -118,7 +118,7 @@ async def async_setup_entry( class Sensor(ZhaEntity, SensorEntity): """Base ZHA sensor.""" - SENSOR_ATTR: int | str | None = None + _attribute_name: int | str | None = None _decimals: int = 1 _divisor: int = 1 _multiplier: int | float = 1 @@ -148,8 +148,8 @@ class Sensor(ZhaEntity, SensorEntity): """ cluster_handler = cluster_handlers[0] if ( - cls.SENSOR_ATTR in cluster_handler.cluster.unsupported_attributes - or cls.SENSOR_ATTR not in cluster_handler.cluster.attributes_by_name + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name ): return None @@ -165,8 +165,8 @@ class Sensor(ZhaEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state of the entity.""" - assert self.SENSOR_ATTR is not None - raw_state = self._cluster_handler.cluster.get(self.SENSOR_ATTR) + assert self._attribute_name is not None + raw_state = self._cluster_handler.cluster.get(self._attribute_name) if raw_state is None: return None return self.formatter(raw_state) @@ -194,8 +194,8 @@ class Sensor(ZhaEntity, SensorEntity): class AnalogInput(Sensor): """Sensor that displays analog input values.""" - SENSOR_ATTR = "present_value" - _attr_name: str = "Analog input" + _attribute_name = "present_value" + _attr_translation_key: str = "analog_input" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) @@ -203,11 +203,10 @@ class AnalogInput(Sensor): class Battery(Sensor): """Battery sensor of power configuration cluster.""" - SENSOR_ATTR = "battery_percentage_remaining" + _attribute_name = "battery_percentage_remaining" _attr_device_class: SensorDeviceClass = SensorDeviceClass.BATTERY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name: str = "Battery" _attr_native_unit_of_measurement = PERCENTAGE @classmethod @@ -262,10 +261,9 @@ class Battery(Sensor): class ElectricalMeasurement(Sensor): """Active power measurement.""" - SENSOR_ATTR = "active_power" + _attribute_name = "active_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Active power" _attr_native_unit_of_measurement: str = UnitOfPower.WATT _div_mul_prefix = "ac_power" @@ -276,7 +274,7 @@ class ElectricalMeasurement(Sensor): 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" + max_attr_name = f"{self._attribute_name}_max" try: max_v = self._cluster_handler.cluster.get(max_attr_name) @@ -319,62 +317,61 @@ class PolledElectricalMeasurement(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementApparentPower( - ElectricalMeasurement, id_suffix="apparent_power" -): +class ElectricalMeasurementApparentPower(ElectricalMeasurement): """Apparent power measurement.""" - SENSOR_ATTR = "apparent_power" + _attribute_name = "apparent_power" + _unique_id_suffix = "apparent_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER - _attr_name: str = "Apparent power" _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE _div_mul_prefix = "ac_power" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"): +class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): """RMS current measurement.""" - SENSOR_ATTR = "rms_current" + _attribute_name = "rms_current" + _unique_id_suffix = "rms_current" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT - _attr_name: str = "RMS current" _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE _div_mul_prefix = "ac_current" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_voltage"): +class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): """RMS Voltage measurement.""" - SENSOR_ATTR = "rms_voltage" + _attribute_name = "rms_voltage" + _unique_id_suffix = "rms_voltage" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE - _attr_name: str = "RMS voltage" _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _div_mul_prefix = "ac_voltage" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_frequency"): +class ElectricalMeasurementFrequency(ElectricalMeasurement): """Frequency measurement.""" - SENSOR_ATTR = "ac_frequency" + _attribute_name = "ac_frequency" + _unique_id_suffix = "ac_frequency" _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY - _attr_name: str = "AC frequency" + _attr_translation_key: str = "ac_frequency" _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ _div_mul_prefix = "ac_frequency" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_factor"): +class ElectricalMeasurementPowerFactor(ElectricalMeasurement): """Frequency measurement.""" - SENSOR_ATTR = "power_factor" + _attribute_name = "power_factor" + _unique_id_suffix = "power_factor" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR - _attr_name: str = "Power factor" _attr_native_unit_of_measurement = PERCENTAGE @@ -390,10 +387,9 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_f class Humidity(Sensor): """Humidity sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Humidity" _divisor = 100 _attr_native_unit_of_measurement = PERCENTAGE @@ -403,10 +399,10 @@ class Humidity(Sensor): class SoilMoisture(Sensor): """Soil Moisture sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Soil moisture" + _attr_translation_key: str = "soil_moisture" _divisor = 100 _attr_native_unit_of_measurement = PERCENTAGE @@ -416,10 +412,10 @@ class SoilMoisture(Sensor): class LeafWetness(Sensor): """Leaf Wetness sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Leaf wetness" + _attr_translation_key: str = "leaf_wetness" _divisor = 100 _attr_native_unit_of_measurement = PERCENTAGE @@ -429,10 +425,9 @@ class LeafWetness(Sensor): class Illuminance(Sensor): """Illuminance Sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ILLUMINANCE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Illuminance" _attr_native_unit_of_measurement = LIGHT_LUX def formatter(self, value: int) -> int: @@ -448,10 +443,10 @@ class Illuminance(Sensor): class SmartEnergyMetering(Sensor): """Metering sensor.""" - SENSOR_ATTR: int | str = "instantaneous_demand" + _attribute_name = "instantaneous_demand" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Instantaneous demand" + _attr_translation_key: str = "instantaneous_demand" unit_of_measure_map = { 0x00: UnitOfPower.WATT, @@ -499,13 +494,14 @@ class SmartEnergyMetering(Sensor): stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): +class SmartEnergySummation(SmartEnergyMetering): """Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_summ_delivered" + _attribute_name = "current_summ_delivered" + _unique_id_suffix = "summation_delivered" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENERGY _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING - _attr_name: str = "Summation delivered" + _attr_translation_key: str = "summation_delivered" unit_of_measure_map = { 0x00: UnitOfEnergy.KILO_WATT_HOUR, @@ -558,13 +554,12 @@ class PolledSmartEnergySummation(SmartEnergySummation): models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier1SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier1_summation_delivered" -): +class Tier1SmartEnergySummation(PolledSmartEnergySummation): """Tier 1 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier1_summ_delivered" - _attr_name: str = "Tier 1 summation delivered" + _attribute_name = "current_tier1_summ_delivered" + _unique_id_suffix = "tier1_summation_delivered" + _attr_translation_key: str = "tier1_summation_delivered" @MULTI_MATCH( @@ -572,13 +567,12 @@ class Tier1SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier2SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier2_summation_delivered" -): +class Tier2SmartEnergySummation(PolledSmartEnergySummation): """Tier 2 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier2_summ_delivered" - _attr_name: str = "Tier 2 summation delivered" + _attribute_name = "current_tier2_summ_delivered" + _unique_id_suffix = "tier2_summation_delivered" + _attr_translation_key: str = "tier2_summation_delivered" @MULTI_MATCH( @@ -586,13 +580,12 @@ class Tier2SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier3SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier3_summation_delivered" -): +class Tier3SmartEnergySummation(PolledSmartEnergySummation): """Tier 3 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier3_summ_delivered" - _attr_name: str = "Tier 3 summation delivered" + _attribute_name = "current_tier3_summ_delivered" + _unique_id_suffix = "tier3_summation_delivered" + _attr_translation_key: str = "tier3_summation_delivered" @MULTI_MATCH( @@ -600,13 +593,12 @@ class Tier3SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier4SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier4_summation_delivered" -): +class Tier4SmartEnergySummation(PolledSmartEnergySummation): """Tier 4 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier4_summ_delivered" - _attr_name: str = "Tier 4 summation delivered" + _attribute_name = "current_tier4_summ_delivered" + _unique_id_suffix = "tier4_summation_delivered" + _attr_translation_key: str = "tier4_summation_delivered" @MULTI_MATCH( @@ -614,13 +606,12 @@ class Tier4SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier5SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier5_summation_delivered" -): +class Tier5SmartEnergySummation(PolledSmartEnergySummation): """Tier 5 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier5_summ_delivered" - _attr_name: str = "Tier 5 summation delivered" + _attribute_name = "current_tier5_summ_delivered" + _unique_id_suffix = "tier5_summation_delivered" + _attr_translation_key: str = "tier5_summation_delivered" @MULTI_MATCH( @@ -628,13 +619,12 @@ class Tier5SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier6SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier6_summation_delivered" -): +class Tier6SmartEnergySummation(PolledSmartEnergySummation): """Tier 6 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier6_summ_delivered" - _attr_name: str = "Tier 6 summation delivered" + _attribute_name = "current_tier6_summ_delivered" + _unique_id_suffix = "tier6_summation_delivered" + _attr_translation_key: str = "tier6_summation_delivered" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) @@ -642,10 +632,9 @@ class Tier6SmartEnergySummation( class Pressure(Sensor): """Pressure sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.PRESSURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Pressure" _decimals = 0 _attr_native_unit_of_measurement = UnitOfPressure.HPA @@ -655,10 +644,9 @@ class Pressure(Sensor): class Temperature(Sensor): """Temperature Sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Temperature" _divisor = 100 _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @@ -668,10 +656,10 @@ class Temperature(Sensor): class DeviceTemperature(Sensor): """Device Temperature Sensor.""" - SENSOR_ATTR = "current_temperature" + _attribute_name = "current_temperature" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Device temperature" + _attr_translation_key: str = "device_temperature" _divisor = 100 _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -682,10 +670,9 @@ class DeviceTemperature(Sensor): class CarbonDioxideConcentration(Sensor): """Carbon Dioxide Concentration sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO2 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Carbon dioxide concentration" _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION @@ -696,10 +683,9 @@ class CarbonDioxideConcentration(Sensor): class CarbonMonoxideConcentration(Sensor): """Carbon Monoxide Concentration sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Carbon monoxide concentration" _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION @@ -711,10 +697,9 @@ class CarbonMonoxideConcentration(Sensor): class VOCLevel(Sensor): """VOC Level sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "VOC level" _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -729,12 +714,11 @@ class VOCLevel(Sensor): class PPBVOCLevel(Sensor): """VOC Level sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = ( SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS ) _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "VOC level" _decimals = 0 _multiplier = 1 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_BILLION @@ -745,10 +729,9 @@ class PPBVOCLevel(Sensor): class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.PM25 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Particulate matter" _decimals = 0 _multiplier = 1 _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -759,9 +742,9 @@ class PM25(Sensor): class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Formaldehyde concentration" + _attr_translation_key: str = "formaldehyde" _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION @@ -772,10 +755,11 @@ class FormaldehydeConcentration(Sensor): stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): +class ThermostatHVACAction(Sensor): """Thermostat HVAC action sensor.""" - _attr_name: str = "HVAC action" + _unique_id_suffix = "hvac_action" + _attr_translation_key: str = "hvac_action" @classmethod def create_entity( @@ -854,11 +838,6 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): return HVACAction.IDLE return HVACAction.OFF - @callback - def async_set_state(self, *args, **kwargs) -> None: - """Handle state update from cluster handler.""" - self.async_write_ha_state() - @MULTI_MATCH( cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT}, @@ -896,17 +875,18 @@ class SinopeHVACAction(ThermostatHVACAction): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class RSSISensor(Sensor, id_suffix="rssi"): +class RSSISensor(Sensor): """RSSI sensor for a device.""" + _attribute_name = "rssi" + _unique_id_suffix = "rssi" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.SIGNAL_STRENGTH _attr_native_unit_of_measurement: str | None = SIGNAL_STRENGTH_DECIBELS_MILLIWATT _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False _attr_should_poll = True # BaseZhaEntity defaults to False - _attr_name: str = "RSSI" - unique_id_suffix: str + _attr_translation_key: str = "rssi" @classmethod def create_entity( @@ -920,7 +900,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): Return entity if it is a supported configuration, otherwise return None """ - key = f"{CLUSTER_HANDLER_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, cluster_handlers, **kwargs) @@ -928,17 +908,19 @@ class RSSISensor(Sensor, id_suffix="rssi"): @property def native_value(self) -> StateType: """Return the state of the entity.""" - return getattr(self._zha_device.device, self.unique_id_suffix) + return getattr(self._zha_device.device, self._attribute_name) @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class LQISensor(RSSISensor, id_suffix="lqi"): +class LQISensor(RSSISensor): """LQI sensor for a device.""" - _attr_name: str = "LQI" + _attribute_name = "lqi" + _unique_id_suffix = "lqi" _attr_device_class = None _attr_native_unit_of_measurement = None + _attr_translation_key = "lqi" @MULTI_MATCH( @@ -948,38 +930,41 @@ class LQISensor(RSSISensor, id_suffix="lqi"): }, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class TimeLeft(Sensor, id_suffix="time_left"): +class TimeLeft(Sensor): """Sensor that displays time left value.""" - SENSOR_ATTR = "timer_time_left" + _attribute_name = "timer_time_left" + _unique_id_suffix = "time_left" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" - _attr_name: str = "Time left" + _attr_translation_key: str = "timer_time_left" _attr_native_unit_of_measurement = UnitOfTime.MINUTES @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") # pylint: disable-next=hass-invalid-inheritance # needs fixing -class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): +class IkeaDeviceRunTime(Sensor): """Sensor that displays device run time (in minutes).""" - SENSOR_ATTR = "device_run_time" + _attribute_name = "device_run_time" + _unique_id_suffix = "device_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" - _attr_name: str = "Device run time" + _attr_translation_key: str = "device_run_time" _attr_native_unit_of_measurement = UnitOfTime.MINUTES _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") # pylint: disable-next=hass-invalid-inheritance # needs fixing -class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): +class IkeaFilterRunTime(Sensor): """Sensor that displays run time of the current filter (in minutes).""" - SENSOR_ATTR = "filter_run_time" + _attribute_name = "filter_run_time" + _unique_id_suffix = "filter_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" - _attr_name: str = "Filter run time" + _attr_translation_key: str = "filter_run_time" _attr_native_unit_of_measurement = UnitOfTime.MINUTES _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC @@ -993,11 +978,12 @@ class AqaraFeedingSource(types.enum8): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): +class AqaraPetFeederLastFeedingSource(Sensor): """Sensor that displays the last feeding source of pet feeder.""" - SENSOR_ATTR = "last_feeding_source" - _attr_name: str = "Last feeding source" + _attribute_name = "last_feeding_source" + _unique_id_suffix = "last_feeding_source" + _attr_translation_key: str = "last_feeding_source" _attr_icon = "mdi:devices" def formatter(self, value: int) -> int | float | None: @@ -1007,32 +993,35 @@ class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): +class AqaraPetFeederLastFeedingSize(Sensor): """Sensor that displays the last feeding size of the pet feeder.""" - SENSOR_ATTR = "last_feeding_size" - _attr_name: str = "Last feeding size" + _attribute_name = "last_feeding_size" + _unique_id_suffix = "last_feeding_size" + _attr_translation_key: str = "last_feeding_size" _attr_icon: str = "mdi:counter" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): +class AqaraPetFeederPortionsDispensed(Sensor): """Sensor that displays the number of portions dispensed by the pet feeder.""" - SENSOR_ATTR = "portions_dispensed" - _attr_name: str = "Portions dispensed today" + _attribute_name = "portions_dispensed" + _unique_id_suffix = "portions_dispensed" + _attr_translation_key: str = "portions_dispensed_today" _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_icon: str = "mdi:counter" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): +class AqaraPetFeederWeightDispensed(Sensor): """Sensor that displays the weight dispensed by the pet feeder.""" - SENSOR_ATTR = "weight_dispensed" - _attr_name: str = "Weight dispensed today" + _attribute_name = "weight_dispensed" + _unique_id_suffix = "weight_dispensed" + _attr_translation_key: str = "weight_dispensed_today" _attr_native_unit_of_measurement = UnitOfMass.GRAMS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_icon: str = "mdi:weight-gram" @@ -1040,11 +1029,12 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraSmokeDensityDbm(Sensor, id_suffix="smoke_density_dbm"): +class AqaraSmokeDensityDbm(Sensor): """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" - SENSOR_ATTR = "smoke_density_dbm" - _attr_name: str = "Smoke density" + _attribute_name = "smoke_density_dbm" + _unique_id_suffix = "smoke_density_dbm" + _attr_translation_key: str = "smoke_density" _attr_native_unit_of_measurement = "dB/m" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_icon: str = "mdi:google-circles-communities" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 79354325fb2..22c2810ad23 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -113,7 +113,7 @@ "data": { "radio_type": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]" }, - "title": "[%key:component::zha::config::step::manual_pick_radio_type::title%]", + "title": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]", "description": "[%key:component::zha::config::step::manual_pick_radio_type::description%]" }, "manual_port_config": { @@ -163,11 +163,11 @@ } }, "error": { - "cannot_connect": "[%key:component::zha::config::error::cannot_connect%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_backup_json": "[%key:component::zha::config::error::invalid_backup_json%]" }, "abort": { - "single_instance_allowed": "[%key:component::zha::config::abort::single_instance_allowed%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "[%key:component::zha::config::abort::not_zha_device%]", "usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]", "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]" @@ -513,6 +513,419 @@ "wrong_silabs_firmware_installed_other": { "title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]", "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). To run your radio exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee radio manufacturer's instructions for how to do this." + }, + "inconsistent_network_settings": { + "title": "Zigbee network settings have changed", + "fix_flow": { + "step": { + "init": { + "title": "[%key:component::zha::issues::inconsistent_network_settings::title%]", + "description": "Your Zigbee radio's network settings are inconsistent with the most recent network backup. This usually happens if another Zigbee integration (e.g. Zigbee2MQTT or deCONZ) has overwritten them.\n\n{diff}\n\nIf you did not intentionally change your network settings, restore from the most recent backup: your devices will not work otherwise.", + "menu_options": { + "use_new_settings": "Keep the new settings", + "restore_old_settings": "Restore backup (recommended)" + } + } + } + } + } + }, + "entity": { + "alarm_control_panel": { + "alarm_control_panel": { + "name": "[%key:component::alarm_control_panel::title%]" + } + }, + "binary_sensor": { + "accelerometer": { + "name": "Accelerometer" + }, + "binary_input": { + "name": "Binary input" + }, + "frost_lock": { + "name": "Frost lock" + }, + "replace_filter": { + "name": "Replace filter" + }, + "consumer_connected": { + "name": "Consumer connected" + }, + "valve_alarm": { + "name": "Valve alarm" + }, + "calibrated": { + "name": "Calibrated" + }, + "external_sensor": { + "name": "External sensor" + }, + "linkage_alarm_state": { + "name": "Linkage alarm state" + }, + "ias_zone": { + "name": "IAS zone" + } + }, + "button": { + "reset_frost_lock": { + "name": "Frost lock reset" + }, + "reset_no_presence_status": { + "name": "Presence status reset" + }, + "feed": { + "name": "Feed" + }, + "self_test": { + "name": "Self-test" + } + }, + "climate": { + "thermostat": { + "name": "[%key:component::climate::entity_component::_::name%]" + } + }, + "cover": { + "cover": { + "name": "[%key:component::cover::title%]" + }, + "shade": { + "name": "[%key:component::cover::entity_component::shade::name%]" + }, + "keen_vent": { + "name": "Keen vent" + } + }, + "fan": { + "fan": { + "name": "[%key:component::fan::title%]" + }, + "fan_group": { + "name": "Fan group" + } + }, + "light": { + "light": { + "name": "[%key:component::light::title%]" + }, + "light_group": { + "name": "Light group" + } + }, + "lock": { + "door_lock": { + "name": "Door lock" + } + }, + "number": { + "number": { + "name": "[%key:component::number::title%]" + }, + "detection_interval": { + "name": "Detection interval" + }, + "on_level": { + "name": "On level" + }, + "on_off_transition_time": { + "name": "On/Off transition time" + }, + "on_transition_time": { + "name": "On transition time" + }, + "off_transition_time": { + "name": "Off transition time" + }, + "default_move_rate": { + "name": "Default move rate" + }, + "start_up_current_level": { + "name": "Start-up current level" + }, + "start_up_color_temperature": { + "name": "Start-up color temperature" + }, + "timer_duration": { + "name": "Timer duration" + }, + "filter_life_time": { + "name": "Filter life time" + }, + "transmit_power": { + "name": "Transmit power" + }, + "dimming_speed_up_remote": { + "name": "Remote dimming up speed" + }, + "button_delay": { + "name": "Button delay" + }, + "dimming_speed_up_local": { + "name": "Local dimming up speed" + }, + "ramp_rate_off_to_on_local": { + "name": "Local ramp rate off to on" + }, + "ramp_rate_off_to_on_remote": { + "name": "Remote ramp rate off to on" + }, + "dimming_speed_down_remote": { + "name": "Remote dimming down speed" + }, + "dimming_speed_down_local": { + "name": "Local dimming down speed" + }, + "ramp_rate_on_to_off_local": { + "name": "Local ramp rate on to off" + }, + "ramp_rate_on_to_off_remote": { + "name": "Remote ramp rate on to off" + }, + "minimum_level": { + "name": "Minimum load dimming level" + }, + "maximum_level": { + "name": "Maximum load dimming level" + }, + "auto_off_timer": { + "name": "Automatic switch shutoff timer" + }, + "load_level_indicator_timeout": { + "name": "Load level indicator timeout" + }, + "led_color_when_on": { + "name": "Default all LED on color" + }, + "led_color_when_off": { + "name": "Default all LED off color" + }, + "led_intensity_when_on": { + "name": "Default all LED on intensity" + }, + "led_intensity_when_off": { + "name": "Default all LED off intensity" + }, + "double_tap_up_level": { + "name": "Double tap up level" + }, + "double_tap_down_level": { + "name": "Double tap down level" + }, + "serving_size": { + "name": "Serving to dispense" + }, + "portion_weight": { + "name": "Portion weight" + }, + "away_preset_temperature": { + "name": "Away preset temperature" + } + }, + "select": { + "default_siren_tone": { + "name": "Default siren tone" + }, + "default_siren_level": { + "name": "Default siren level" + }, + "default_strobe_level": { + "name": "Default strobe level" + }, + "default_strobe": { + "name": "Default strobe" + }, + "start_up_on_off": { + "name": "Start-up behavior" + }, + "power_on_state": { + "name": "Power on state" + }, + "backlight_mode": { + "name": "Backlight mode" + }, + "motion_sensitivity": { + "name": "Motion sensitivity" + }, + "monitoring_mode": { + "name": "Monitoring mode" + }, + "approach_distance": { + "name": "Approach distance" + }, + "window_covering_mode": { + "name": "Curtain mode" + }, + "output_mode": { + "name": "Output mode" + }, + "switch_type": { + "name": "Switch type" + }, + "led_scaling_mode": { + "name": "Led scaling mode" + }, + "increased_non_neutral_output": { + "name": "Non neutral output" + }, + "feeding_mode": { + "name": "Mode" + }, + "preset": { + "name": "Preset" + } + }, + "sensor": { + "analog_input": { + "name": "Analog input" + }, + "ac_frequency": { + "name": "AC frequency" + }, + "soil_moisture": { + "name": "Soil moisture" + }, + "leaf_wetness": { + "name": "Leaf wetness" + }, + "instantaneous_demand": { + "name": "Instantaneous demand" + }, + "summation_delivered": { + "name": "Summation delivered" + }, + "tier1_summation_delivered": { + "name": "Tier 1 summation delivered" + }, + "tier2_summation_delivered": { + "name": "Tier 2 summation delivered" + }, + "tier3_summation_delivered": { + "name": "Tier 3 summation delivered" + }, + "tier4_summation_delivered": { + "name": "Tier 4 summation delivered" + }, + "tier5_summation_delivered": { + "name": "Tier 5 summation delivered" + }, + "tier6_summation_delivered": { + "name": "Tier 6 summation delivered" + }, + "device_temperature": { + "name": "Device temperature" + }, + "formaldehyde": { + "name": "Formaldehyde concentration" + }, + "hvac_action": { + "name": "HVAC action" + }, + "rssi": { + "name": "RSSI" + }, + "lqi": { + "name": "LQI" + }, + "timer_time_left": { + "name": "Time left" + }, + "device_run_time": { + "name": "Device run time" + }, + "filter_run_time": { + "name": "Filter run time" + }, + "last_feeding_source": { + "name": "Last feeding source" + }, + "last_feeding_size": { + "name": "Last feeding size" + }, + "portions_dispensed_today": { + "name": "Portions dispensed today" + }, + "weight_dispensed_today": { + "name": "Weight dispensed today" + }, + "smoke_density": { + "name": "Smoke density" + } + }, + "switch": { + "switch": { + "name": "[%key:component::switch::title%]" + }, + "window_detection_function": { + "name": "Invert window detection" + }, + "trigger_indicator": { + "name": "LED trigger indicator" + }, + "power_outage_memory": { + "name": "Power outage memory" + }, + "child_lock": { + "name": "Child lock" + }, + "disable_led": { + "name": "Disable LED" + }, + "invert_switch": { + "name": "Invert switch" + }, + "smart_bulb_mode": { + "name": "Smart bulb mode" + }, + "double_tap_up_enabled": { + "name": "Double tap up enabled" + }, + "double_tap_down_enabled": { + "name": "Double tap down enabled" + }, + "aux_switch_scenes": { + "name": "Aux switch scenes" + }, + "binding_off_to_on_sync_level": { + "name": "Binding off to on sync level" + }, + "local_protection": { + "name": "Local protection" + }, + "one_led_mode": { + "name": "Only 1 LED mode" + }, + "firmware_progress_led": { + "name": "Firmware progress LED" + }, + "relay_click_in_on_off_mode": { + "name": "Disable relay click in on off mode" + }, + "disable_clear_notifications_double_tap": { + "name": "Disable config 2x tap to clear notifications" + }, + "led_indicator": { + "name": "LED indicator" + }, + "window_detection": { + "name": "Window detection" + }, + "valve_detection": { + "name": "Valve detection" + }, + "heartbeat_indicator": { + "name": "Heartbeat indicator" + }, + "linkage_alarm": { + "name": "Linkage alarm" + }, + "buzzer_manual_mute": { + "name": "Buzzer manual mute" + }, + "buzzer_manual_alarm": { + "name": "Buzzer manual alarm" + } } } } diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index eff8f727c1c..e49bc44b822 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -63,7 +63,7 @@ async def async_setup_entry( class Switch(ZhaEntity, SwitchEntity): """ZHA switch.""" - _attr_name: str = "Switch" + _attr_translation_key = "switch" def __init__( self, @@ -168,8 +168,8 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): """Representation of a ZHA switch configuration entity.""" _attr_entity_category = EntityCategory.CONFIG - _zcl_attribute: str - _zcl_inverter_attribute: str | None = None + _attribute_name: str + _inverter_attribute_name: str | None = None _force_inverted: bool = False @classmethod @@ -186,13 +186,13 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): """ cluster_handler = cluster_handlers[0] if ( - cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes - or cls._zcl_attribute not in cluster_handler.cluster.attributes_by_name - or cluster_handler.cluster.get(cls._zcl_attribute) is None + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", - cls._zcl_attribute, + cls._attribute_name, cls.__name__, ) return None @@ -225,20 +225,22 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): @property def inverted(self) -> bool: """Return True if the switch is inverted.""" - if self._zcl_inverter_attribute: - return bool(self._cluster_handler.cluster.get(self._zcl_inverter_attribute)) + if self._inverter_attribute_name: + return bool( + self._cluster_handler.cluster.get(self._inverter_attribute_name) + ) return self._force_inverted @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - val = bool(self._cluster_handler.cluster.get(self._zcl_attribute)) + val = bool(self._cluster_handler.cluster.get(self._attribute_name)) return (not val) if self.inverted else val async def async_turn_on_off(self, state: bool) -> None: """Turn the entity on or off.""" await self._cluster_handler.write_attributes_safe( - {self._zcl_attribute: not state if self.inverted else state} + {self._attribute_name: not state if self.inverted else state} ) self.async_write_ha_state() @@ -256,10 +258,10 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): self.error("Polling current state") if self._cluster_handler: value = await self._cluster_handler.get_attribute_value( - self._zcl_attribute, from_cache=False + self._attribute_name, from_cache=False ) await self._cluster_handler.get_attribute_value( - self._zcl_inverter_attribute, from_cache=False + self._inverter_attribute_name, from_cache=False ) self.debug("read value=%s, inverted=%s", value, self.inverted) @@ -270,39 +272,36 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): "_TZE200_b6wax7g0", }, ) -class OnOffWindowDetectionFunctionConfigurationEntity( - ZHASwitchConfigurationEntity, id_suffix="on_off_window_opened_detection" -): +class OnOffWindowDetectionFunctionConfigurationEntity(ZHASwitchConfigurationEntity): """Representation of a ZHA window detection configuration entity.""" - _zcl_attribute: str = "window_detection_function" - _zcl_inverter_attribute: str = "window_detection_function_inverter" - _attr_name: str = "Invert window detection" + _unique_id_suffix = "on_off_window_opened_detection" + _attribute_name = "window_detection_function" + _inverter_attribute_name = "window_detection_function_inverter" + _attr_translation_key = "window_detection_function" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac02"} ) -class P1MotionTriggerIndicatorSwitch( - ZHASwitchConfigurationEntity, id_suffix="trigger_indicator" -): +class P1MotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): """Representation of a ZHA motion triggering configuration entity.""" - _zcl_attribute: str = "trigger_indicator" - _attr_name = "LED trigger indicator" + _unique_id_suffix = "trigger_indicator" + _attribute_name = "trigger_indicator" + _attr_translation_key = "trigger_indicator" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, ) -class XiaomiPlugPowerOutageMemorySwitch( - ZHASwitchConfigurationEntity, id_suffix="power_outage_memory" -): +class XiaomiPlugPowerOutageMemorySwitch(ZHASwitchConfigurationEntity): """Representation of a ZHA power outage memory configuration entity.""" - _zcl_attribute: str = "power_outage_memory" - _attr_name = "Power outage memory" + _unique_id_suffix = "power_outage_memory" + _attribute_name = "power_outage_memory" + _attr_translation_key = "power_outage_memory" @CONFIG_DIAGNOSTIC_MATCH( @@ -310,173 +309,168 @@ class XiaomiPlugPowerOutageMemorySwitch( manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001", "SML002", "SML003", "SML004"}, ) -class HueMotionTriggerIndicatorSwitch( - ZHASwitchConfigurationEntity, id_suffix="trigger_indicator" -): +class HueMotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): """Representation of a ZHA motion triggering configuration entity.""" - _zcl_attribute: str = "trigger_indicator" - _attr_name = "LED trigger indicator" + _unique_id_suffix = "trigger_indicator" + _attribute_name = "trigger_indicator" + _attr_translation_key = "trigger_indicator" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) -class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): +class ChildLock(ZHASwitchConfigurationEntity): """ZHA BinarySensor.""" - _zcl_attribute: str = "child_lock" - _attr_name = "Child lock" + _unique_id_suffix = "child_lock" + _attribute_name = "child_lock" + _attr_translation_key = "child_lock" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) -class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): +class DisableLed(ZHASwitchConfigurationEntity): """ZHA BinarySensor.""" - _zcl_attribute: str = "disable_led" - _attr_name = "Disable LED" + _unique_id_suffix = "disable_led" + _attribute_name = "disable_led" + _attr_translation_key = "disable_led" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliInvertSwitch(ZHASwitchConfigurationEntity, id_suffix="invert_switch"): +class InovelliInvertSwitch(ZHASwitchConfigurationEntity): """Inovelli invert switch control.""" - _zcl_attribute: str = "invert_switch" - _attr_name: str = "Invert switch" + _unique_id_suffix = "invert_switch" + _attribute_name = "invert_switch" + _attr_translation_key = "invert_switch" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_mode"): +class InovelliSmartBulbMode(ZHASwitchConfigurationEntity): """Inovelli smart bulb mode control.""" - _zcl_attribute: str = "smart_bulb_mode" - _attr_name: str = "Smart bulb mode" + _unique_id_suffix = "smart_bulb_mode" + _attribute_name = "smart_bulb_mode" + _attr_translation_key = "smart_bulb_mode" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDoubleTapUpEnabled( - ZHASwitchConfigurationEntity, id_suffix="double_tap_up_enabled" -): +class InovelliDoubleTapUpEnabled(ZHASwitchConfigurationEntity): """Inovelli double tap up enabled.""" - _zcl_attribute: str = "double_tap_up_enabled" - _attr_name: str = "Double tap up enabled" + _unique_id_suffix = "double_tap_up_enabled" + _attribute_name = "double_tap_up_enabled" + _attr_translation_key = "double_tap_up_enabled" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDoubleTapDownEnabled( - ZHASwitchConfigurationEntity, id_suffix="double_tap_down_enabled" -): +class InovelliDoubleTapDownEnabled(ZHASwitchConfigurationEntity): """Inovelli double tap down enabled.""" - _zcl_attribute: str = "double_tap_down_enabled" - _attr_name: str = "Double tap down enabled" + _unique_id_suffix = "double_tap_down_enabled" + _attribute_name = "double_tap_down_enabled" + _attr_translation_key = "double_tap_down_enabled" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliAuxSwitchScenes( - ZHASwitchConfigurationEntity, id_suffix="aux_switch_scenes" -): +class InovelliAuxSwitchScenes(ZHASwitchConfigurationEntity): """Inovelli unique aux switch scenes.""" - _zcl_attribute: str = "aux_switch_scenes" - _attr_name: str = "Aux switch scenes" + _unique_id_suffix = "aux_switch_scenes" + _attribute_name = "aux_switch_scenes" + _attr_translation_key = "aux_switch_scenes" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliBindingOffToOnSyncLevel( - ZHASwitchConfigurationEntity, id_suffix="binding_off_to_on_sync_level" -): +class InovelliBindingOffToOnSyncLevel(ZHASwitchConfigurationEntity): """Inovelli send move to level with on/off to bound devices.""" - _zcl_attribute: str = "binding_off_to_on_sync_level" - _attr_name: str = "Binding off to on sync level" + _unique_id_suffix = "binding_off_to_on_sync_level" + _attribute_name = "binding_off_to_on_sync_level" + _attr_translation_key = "binding_off_to_on_sync_level" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliLocalProtection( - ZHASwitchConfigurationEntity, id_suffix="local_protection" -): +class InovelliLocalProtection(ZHASwitchConfigurationEntity): """Inovelli local protection control.""" - _zcl_attribute: str = "local_protection" - _attr_name: str = "Local protection" + _unique_id_suffix = "local_protection" + _attribute_name = "local_protection" + _attr_translation_key = "local_protection" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity, id_suffix="on_off_led_mode"): +class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity): """Inovelli only 1 LED mode control.""" - _zcl_attribute: str = "on_off_led_mode" - _attr_name: str = "Only 1 LED mode" + _unique_id_suffix = "on_off_led_mode" + _attribute_name = "on_off_led_mode" + _attr_translation_key = "one_led_mode" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliFirmwareProgressLED( - ZHASwitchConfigurationEntity, id_suffix="firmware_progress_led" -): +class InovelliFirmwareProgressLED(ZHASwitchConfigurationEntity): """Inovelli firmware progress LED control.""" - _zcl_attribute: str = "firmware_progress_led" - _attr_name: str = "Firmware progress LED" + _unique_id_suffix = "firmware_progress_led" + _attribute_name = "firmware_progress_led" + _attr_translation_key = "firmware_progress_led" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliRelayClickInOnOffMode( - ZHASwitchConfigurationEntity, id_suffix="relay_click_in_on_off_mode" -): +class InovelliRelayClickInOnOffMode(ZHASwitchConfigurationEntity): """Inovelli relay click in on off mode control.""" - _zcl_attribute: str = "relay_click_in_on_off_mode" - _attr_name: str = "Disable relay click in on off mode" + _unique_id_suffix = "relay_click_in_on_off_mode" + _attribute_name = "relay_click_in_on_off_mode" + _attr_translation_key = "relay_click_in_on_off_mode" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDisableDoubleTapClearNotificationsMode( - ZHASwitchConfigurationEntity, id_suffix="disable_clear_notifications_double_tap" -): +class InovelliDisableDoubleTapClearNotificationsMode(ZHASwitchConfigurationEntity): """Inovelli disable clear notifications double tap control.""" - _zcl_attribute: str = "disable_clear_notifications_double_tap" - _attr_name: str = "Disable config 2x tap to clear notifications" + _unique_id_suffix = "disable_clear_notifications_double_tap" + _attribute_name = "disable_clear_notifications_double_tap" + _attr_translation_key = "disable_clear_notifications_double_tap" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -class AqaraPetFeederLEDIndicator( - ZHASwitchConfigurationEntity, id_suffix="disable_led_indicator" -): +class AqaraPetFeederLEDIndicator(ZHASwitchConfigurationEntity): """Representation of a LED indicator configuration entity.""" - _zcl_attribute: str = "disable_led_indicator" - _attr_name = "LED indicator" + _unique_id_suffix = "disable_led_indicator" + _attribute_name = "disable_led_indicator" + _attr_translation_key = "led_indicator" _force_inverted = True _attr_icon: str = "mdi:led-on" @@ -484,11 +478,12 @@ class AqaraPetFeederLEDIndicator( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): +class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity): """Representation of a child lock configuration entity.""" - _zcl_attribute: str = "child_lock" - _attr_name = "Child lock" + _unique_id_suffix = "child_lock" + _attribute_name = "child_lock" + _attr_translation_key = "child_lock" _attr_icon: str = "mdi:account-lock" @@ -496,94 +491,92 @@ class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity, id_suffix="child_loc cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F"}, ) -class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): +class TuyaChildLockSwitch(ZHASwitchConfigurationEntity): """Representation of a child lock configuration entity.""" - _zcl_attribute: str = "child_lock" - _attr_name = "Child lock" + _unique_id_suffix = "child_lock" + _attribute_name = "child_lock" + _attr_translation_key = "child_lock" _attr_icon: str = "mdi:account-lock" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatWindowDetection( - ZHASwitchConfigurationEntity, id_suffix="window_detection" -): +class AqaraThermostatWindowDetection(ZHASwitchConfigurationEntity): """Representation of an Aqara thermostat window detection configuration entity.""" - _zcl_attribute: str = "window_detection" - _attr_name = "Window detection" + _unique_id_suffix = "window_detection" + _attribute_name = "window_detection" + _attr_translation_key = "window_detection" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatValveDetection( - ZHASwitchConfigurationEntity, id_suffix="valve_detection" -): +class AqaraThermostatValveDetection(ZHASwitchConfigurationEntity): """Representation of an Aqara thermostat valve detection configuration entity.""" - _zcl_attribute: str = "valve_detection" - _attr_name = "Valve detection" + _unique_id_suffix = "valve_detection" + _attribute_name = "valve_detection" + _attr_translation_key = "valve_detection" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): +class AqaraThermostatChildLock(ZHASwitchConfigurationEntity): """Representation of an Aqara thermostat child lock configuration entity.""" - _zcl_attribute: str = "child_lock" - _attr_name = "Child lock" + _unique_id_suffix = "child_lock" + _attribute_name = "child_lock" + _attr_translation_key = "child_lock" _attr_icon: str = "mdi:account-lock" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraHeartbeatIndicator( - ZHASwitchConfigurationEntity, id_suffix="heartbeat_indicator" -): +class AqaraHeartbeatIndicator(ZHASwitchConfigurationEntity): """Representation of a heartbeat indicator configuration entity for Aqara smoke sensors.""" - _zcl_attribute: str = "heartbeat_indicator" - _attr_name = "Heartbeat indicator" + _unique_id_suffix = "heartbeat_indicator" + _attribute_name = "heartbeat_indicator" + _attr_translation_key = "heartbeat_indicator" _attr_icon: str = "mdi:heart-flash" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraLinkageAlarm(ZHASwitchConfigurationEntity, id_suffix="linkage_alarm"): +class AqaraLinkageAlarm(ZHASwitchConfigurationEntity): """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" - _zcl_attribute: str = "linkage_alarm" - _attr_name = "Linkage alarm" + _unique_id_suffix = "linkage_alarm" + _attribute_name = "linkage_alarm" + _attr_translation_key = "linkage_alarm" _attr_icon: str = "mdi:shield-link-variant" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraBuzzerManualMute( - ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_mute" -): +class AqaraBuzzerManualMute(ZHASwitchConfigurationEntity): """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" - _zcl_attribute: str = "buzzer_manual_mute" - _attr_name = "Buzzer manual mute" + _unique_id_suffix = "buzzer_manual_mute" + _attribute_name = "buzzer_manual_mute" + _attr_translation_key = "buzzer_manual_mute" _attr_icon: str = "mdi:volume-off" @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraBuzzerManualAlarm( - ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_alarm" -): +class AqaraBuzzerManualAlarm(ZHASwitchConfigurationEntity): """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" - _zcl_attribute: str = "buzzer_manual_alarm" - _attr_name = "Buzzer manual alarm" + _unique_id_suffix = "buzzer_manual_alarm" + _attribute_name = "buzzer_manual_alarm" + _attr_translation_key = "buzzer_manual_alarm" _attr_icon: str = "mdi:bullhorn" diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index b9a26630406..a8b3d300e3b 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -113,6 +113,7 @@ from .helpers import ( async_enable_statistics, get_device_id, get_device_id_ext, + get_network_identifier_for_notification, get_unique_id, get_valueless_base_unique_id, ) @@ -448,6 +449,28 @@ class ControllerEvents: "remove_entity" ), ) + elif reason == RemoveNodeReason.RESET: + device_name = device.name_by_user or device.name or f"Node {node.node_id}" + identifier = get_network_identifier_for_notification( + self.hass, self.config_entry, self.driver_events.driver.controller + ) + notification_msg = ( + f"`{device_name}` has been factory reset " + "and removed from the Z-Wave network" + ) + if identifier: + # Remove trailing comma if it's there + if identifier[-1] == ",": + identifier = identifier[:-1] + notification_msg = f"{notification_msg} {identifier}." + else: + notification_msg = f"{notification_msg}." + async_create( + self.hass, + notification_msg, + "Device Was Factory Reset!", + f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}", + ) else: self.remove_device(device) @@ -459,26 +482,17 @@ class ControllerEvents: dev_id = get_device_id(self.driver_events.driver, node) device = self.dev_reg.async_get_device(identifiers={dev_id}) assert device - device_name = device.name_by_user or device.name - home_id = self.driver_events.driver.controller.home_id - # We do this because we know at this point the controller has its home ID as - # as it is part of the device ID - assert home_id + device_name = device.name_by_user or device.name or f"Node {node.node_id}" # In case the user has multiple networks, we should give them more information # about the network for the controller being identified. - identifier = "" - if len(self.hass.config_entries.async_entries(DOMAIN)) > 1: - if str(home_id) != self.config_entry.title: - identifier = ( - f"`{self.config_entry.title}`, with the home ID `{home_id}`, " - ) - else: - identifier = f"with the home ID `{home_id}` " + identifier = get_network_identifier_for_notification( + self.hass, self.config_entry, self.driver_events.driver.controller + ) async_create( self.hass, ( f"`{device_name}` has just requested the controller for your Z-Wave " - f"network {identifier}to identify itself. No action is needed from " + f"network {identifier} to identify itself. No action is needed from " "you other than to note the source of the request, and you can safely " "dismiss this notification when ready." ), @@ -915,6 +929,7 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" info = hass.data[DOMAIN][entry.entry_id] + client: ZwaveClient = info[DATA_CLIENT] driver_events: DriverEvents = info[DATA_DRIVER_EVENTS] tasks: list[Coroutine] = [ @@ -925,8 +940,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = all(await asyncio.gather(*tasks)) if tasks else True - if hasattr(driver_events, "driver"): - await async_disable_server_logging_if_needed(hass, entry, driver_events.driver) + if client.connected and client.driver: + await async_disable_server_logging_if_needed(hass, entry, client.driver) if DATA_CLIENT_LISTEN_TASK in info: await disconnect_client(hass, entry) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 8658dc1cc1f..a917aa44889 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -447,6 +447,7 @@ def async_register_api(hass: HomeAssistant) -> None: hass, websocket_subscribe_controller_statistics ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) + websocket_api.async_register_command(hass, websocket_hard_reset_controller) hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @@ -2441,3 +2442,45 @@ async def websocket_subscribe_node_statistics( }, ) ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/hard_reset_controller", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_hard_reset_controller( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Hard reset controller.""" + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + @callback + def _handle_device_added(device: dr.DeviceEntry) -> None: + """Handle device is added.""" + if entry.entry_id in device.config_entries: + connection.send_result(msg[ID], device.id) + async_cleanup() + + msg[DATA_UNSUBSCRIBE] = unsubs = [ + async_dispatcher_connect( + hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added + ) + ] + await driver.async_hard_reset() diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 28084eecfa6..d511a030fb1 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -259,11 +259,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): def _current_mode_setpoint_enums(self) -> list[ThermostatSetpointType]: """Return the list of enums that are relevant to the current thermostat mode.""" if self._current_mode is None or self._current_mode.value is None: - # Thermostat with no support for setting a mode is just a setpoint - if self.info.primary_value.property_key is None: - return [] - return [ThermostatSetpointType(int(self.info.primary_value.property_key))] - + # Thermostat(valve) with no support for setting a mode + # is considered heating-only + return [ThermostatSetpointType.HEATING] return THERMOSTAT_MODE_SETPOINT_MAP.get(int(self._current_mode.value), []) @property diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 0a3f61fd824..39d8c0e8855 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -162,6 +162,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): any_available_states: set[tuple[int, str]] | None = None # [optional] the value's value must match this value value: Any | None = None + # [optional] the value's metadata_stateful must match this value + stateful: bool | None = None @dataclass @@ -853,26 +855,6 @@ DISCOVERY_SCHEMAS = [ allow_multi=True, entity_registry_enabled_default=False, ), - # number for Basic CC - ZWaveDiscoverySchema( - platform=Platform.NUMBER, - hint="Basic", - primary_value=ZWaveValueDiscoverySchema( - command_class={CommandClass.BASIC}, - type={ValueType.NUMBER}, - property={CURRENT_VALUE_PROPERTY}, - ), - required_values=[ - ZWaveValueDiscoverySchema( - command_class={ - CommandClass.BASIC, - }, - type={ValueType.NUMBER}, - property={TARGET_VALUE_PROPERTY}, - ) - ], - entity_registry_enabled_default=False, - ), # number for Indicator CC (exclude property keys 3-5) ZWaveDiscoverySchema( platform=Platform.NUMBER, @@ -997,6 +979,24 @@ DISCOVERY_SCHEMAS = [ platform=Platform.LIGHT, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), + # light for Basic CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BASIC}, + type={ValueType.NUMBER}, + property={CURRENT_VALUE_PROPERTY}, + ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={ + CommandClass.BASIC, + }, + type={ValueType.NUMBER}, + property={TARGET_VALUE_PROPERTY}, + ) + ], + ), # sirens ZWaveDiscoverySchema( platform=Platform.SIREN, @@ -1047,6 +1047,15 @@ DISCOVERY_SCHEMAS = [ any_available_states={(0, "idle")}, ), ), + # event + # stateful = False + ZWaveDiscoverySchema( + platform=Platform.EVENT, + hint="stateless", + primary_value=ZWaveValueDiscoverySchema( + stateful=False, + ), + ), ] @@ -1296,6 +1305,9 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: # check value if schema.value is not None and value.value not in schema.value: return False + # check metadata_stateful + if schema.stateful is not None and value.metadata.stateful != schema.stateful: + return False return True diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 7a274df41f2..b633e2a614f 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass, field import logging -from typing import Any, cast +from typing import Any, TypeVar, cast from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.energy_production import ( @@ -87,6 +87,7 @@ from zwave_js_server.const.command_class.multilevel_sensor import ( MultilevelSensorScaleType, MultilevelSensorType, ) +from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue as ZwaveConfigurationValue, @@ -355,24 +356,22 @@ class NumericSensorDataTemplateData: unit_of_measurement: str | None = None +T = TypeVar( + "T", + MultilevelSensorType, + MultilevelSensorScaleType, + MeterScaleType, + EnergyProductionParameter, + EnergyProductionScaleType, +) + + class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): """Data template class for Z-Wave Sensor entities.""" @staticmethod def find_key_from_matching_set( - enum_value: MultilevelSensorType - | MultilevelSensorScaleType - | MeterScaleType - | EnergyProductionParameter - | EnergyProductionScaleType, - set_map: Mapping[ - str, - list[MultilevelSensorType] - | list[MultilevelSensorScaleType] - | list[MeterScaleType] - | list[EnergyProductionScaleType] - | list[EnergyProductionParameter], - ], + enum_value: T, set_map: Mapping[str, list[T]] ) -> str | None: """Find a key in a set map that matches a given enum value.""" for key, value_set in set_map.items(): @@ -393,7 +392,11 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): return NumericSensorDataTemplateData(ENTITY_DESC_KEY_BATTERY, PERCENTAGE) if value.command_class == CommandClass.METER: - meter_scale_type = get_meter_scale_type(value) + try: + meter_scale_type = get_meter_scale_type(value) + except UnknownValueData: + return NumericSensorDataTemplateData() + unit = self.find_key_from_matching_set(meter_scale_type, METER_UNIT_MAP) # We do this because even though these are energy scales, they don't meet # the unit requirements for the energy device class. @@ -418,8 +421,11 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): ) if value.command_class == CommandClass.SENSOR_MULTILEVEL: - sensor_type = get_multilevel_sensor_type(value) - multilevel_sensor_scale_type = get_multilevel_sensor_scale_type(value) + try: + sensor_type = get_multilevel_sensor_type(value) + multilevel_sensor_scale_type = get_multilevel_sensor_scale_type(value) + except UnknownValueData: + return NumericSensorDataTemplateData() unit = self.find_key_from_matching_set( multilevel_sensor_scale_type, MULTILEVEL_SENSOR_UNIT_MAP ) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 0b9c68e9664..e7e110e7db6 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -206,7 +206,7 @@ class ZWaveBaseEntity(Entity): ): name += f" ({primary_value.endpoint})" - return name + return name.strip() @property def available(self) -> bool: diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py new file mode 100644 index 00000000000..93860b6273e --- /dev/null +++ b/homeassistant/components/zwave_js/event.py @@ -0,0 +1,98 @@ +"""Support for Z-Wave controls using the event platform.""" +from __future__ import annotations + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.model.driver import Driver +from zwave_js_server.model.value import Value, ValueNotification + +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_VALUE, DATA_CLIENT, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Z-Wave Event entity from Config Entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_event(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave event entity.""" + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. + entities: list[ZWaveBaseEntity] = [ZwaveEventEntity(config_entry, driver, info)] + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{EVENT_DOMAIN}", + async_add_event, + ) + ) + + +def _cc_and_label(value: Value) -> str: + """Return a string with the command class and label.""" + label = value.metadata.label + if label: + label = label.lower() + return f"{value.command_class_name.capitalize()} {label}".strip() + + +class ZwaveEventEntity(ZWaveBaseEntity, EventEntity): + """Representation of a Z-Wave event entity.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveEventEntity entity.""" + super().__init__(config_entry, driver, info) + value = self.value = info.primary_value + self.states: dict[int, str] = {} + + if states := value.metadata.states: + self._attr_event_types = sorted(states.values()) + self.states = {int(k): v for k, v in states.items()} + else: + self._attr_event_types = [_cc_and_label(value)] + # Entity class attributes + self._attr_name = self.generate_name(include_value_name=True) + + @callback + def _async_handle_event(self, value_notification: ValueNotification) -> None: + """Handle a value notification event.""" + # If the notification doesn't match the value we are tracking, we can return + value = self.value + if ( + value_notification.command_class != value.command_class + or value_notification.endpoint != value.endpoint + or value_notification.property_ != value.property_ + or value_notification.property_key != value.property_key + or (notification_value := value_notification.value) is None + ): + return + event_name = self.states.get(notification_value, _cc_and_label(value)) + self._trigger_event(event_name, {ATTR_VALUE: notification_value}) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self.info.node.on( + "value notification", + lambda event: self._async_handle_event(event["value_notification"]), + ) + ) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 8774bcea73f..5d78d3e57e7 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -14,6 +14,7 @@ from zwave_js_server.const import ( ConfigurationValueType, LogLevel, ) +from zwave_js_server.model.controller import Controller from zwave_js_server.model.driver import Driver from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.node import Node as ZwaveNode @@ -512,3 +513,15 @@ def get_device_info(driver: Driver, node: ZwaveNode) -> DeviceInfo: manufacturer=node.device_config.manufacturer, suggested_area=node.location if node.location else None, ) + + +def get_network_identifier_for_notification( + hass: HomeAssistant, config_entry: ConfigEntry, controller: Controller +) -> str: + """Return the network identifier string for persistent notifications.""" + home_id = str(controller.home_id) + if len(hass.config_entries.async_entries(DOMAIN)) > 1: + if str(home_id) != config_entry.title: + return f"`{config_entry.title}`, with the home ID `{home_id}`," + return f"with the home ID `{home_id}`" + return "" diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 1a9abb9b0f8..8ba50c15e02 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -129,11 +129,22 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._supported_color_modes: set[ColorMode] = set() # get additional (optional) values and set features + # If the command class is Basic, we must geenerate a name that includes + # the command class name to avoid ambiguity self._target_brightness = self.get_zwave_value( TARGET_VALUE_PROPERTY, CommandClass.SWITCH_MULTILEVEL, add_to_watched_value_ids=False, ) + if self.info.primary_value.command_class == CommandClass.BASIC: + self._attr_name = self.generate_name( + include_value_name=True, alternate_value_name="Basic" + ) + self._target_brightness = self.get_zwave_value( + TARGET_VALUE_PROPERTY, + CommandClass.BASIC, + add_to_watched_value_ids=False, + ) self._target_color = self.get_zwave_value( TARGET_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, @@ -356,7 +367,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # typically delayed and causes a confusing UX. if ( zwave_brightness == SET_TO_PREVIOUS_VALUE - and self.info.primary_value.command_class == CommandClass.SWITCH_MULTILEVEL + and self.info.primary_value.command_class + in (CommandClass.BASIC, CommandClass.SWITCH_MULTILEVEL) ): self._set_optimistic_state = True self.async_write_ha_state() diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 505196c43eb..f0c1dcec6b5 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.53.1"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 6de6b0f4e45..7df88f7dca4 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -72,6 +72,8 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): if self._attr_available_tones: self._attr_supported_features |= SirenEntityFeature.TONES + self._attr_name = self.generate_name(include_value_name=True) + @property def is_on(self) -> bool | None: """Return whether device is on.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index 7c3bd2e7bfe..1b7e90996dc 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -88,6 +88,8 @@ INTEGRATION_LOAD_EXCEPTIONS = ( *LOAD_EXCEPTIONS, ) +SAFE_MODE_FILENAME = "safe-mode" + DEFAULT_CONFIG = f""" # Loads default set of integrations. Do not remove. default_config: @@ -378,14 +380,16 @@ def _write_default_config(config_dir: str) -> bool: return True except OSError: - print("Unable to create default configuration file", config_path) # noqa: T201 + print( # noqa: T201 + f"Unable to create default configuration file {config_path}" + ) return False async def async_hass_config_yaml(hass: HomeAssistant) -> dict: """Load YAML from a Home Assistant configuration file. - This function allow a component inside the asyncio loop to reload its + This function allows a component inside the asyncio loop to reload its configuration by itself. Include package merge. """ secrets = Secrets(Path(hass.config.config_dir)) @@ -1005,3 +1009,24 @@ def async_notify_setup_error( persistent_notification.async_create( hass, message, "Invalid config", "invalid_config" ) + + +def safe_mode_enabled(config_dir: str) -> bool: + """Return if safe mode is enabled. + + If safe mode is enabled, the safe mode file will be removed. + """ + safe_mode_path = os.path.join(config_dir, SAFE_MODE_FILENAME) + safe_mode = os.path.exists(safe_mode_path) + if safe_mode: + os.remove(safe_mode_path) + return safe_mode + + +async def async_enable_safe_mode(hass: HomeAssistant) -> None: + """Enable safe mode.""" + + def _enable_safe_mode() -> None: + Path(hass.config.path(SAFE_MODE_FILENAME)).touch() + + await hass.async_add_executor_job(_enable_safe_mode) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ed5ba79c1b4..2b8f1ec4065 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -223,6 +223,7 @@ class ConfigEntry: "_async_cancel_retry_setup", "_on_unload", "reload_lock", + "_reauth_lock", "_tasks", "_background_tasks", "_integration_for_domain", @@ -321,6 +322,8 @@ class ConfigEntry: # Reload lock to prevent conflicting reloads self.reload_lock = asyncio.Lock() + # Reauth lock to prevent concurrent reauth flows + self._reauth_lock = asyncio.Lock() self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() @@ -437,27 +440,16 @@ class ConfigEntry: self._tries += 1 message = str(ex) ready_message = f"ready yet: {message}" if message else "ready yet" - if self._tries == 1: - _LOGGER.warning( - ( - "Config entry '%s' for %s integration not %s; Retrying in" - " background" - ), - self.title, - self.domain, - ready_message, - ) - else: - _LOGGER.debug( - ( - "Config entry '%s' for %s integration not %s; Retrying in %d" - " seconds" - ), - self.title, - self.domain, - ready_message, - wait_time, - ) + _LOGGER.debug( + ( + "Config entry '%s' for %s integration not %s; Retrying in %d" + " seconds" + ), + self.title, + self.domain, + ready_message, + wait_time, + ) if hass.state == CoreState.running: self._async_cancel_retry_setup = async_call_later( @@ -738,12 +730,28 @@ class ConfigEntry: data: dict[str, Any] | None = None, ) -> None: """Start a reauth flow.""" + # We will check this again in the task when we hold the lock, + # but we also check it now to try to avoid creating the task. if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): # Reauth flow already in progress for this entry return - hass.async_create_task( - hass.config_entries.flow.async_init( + self._async_init_reauth(hass, context, data), + f"config entry reauth {self.title} {self.domain} {self.entry_id}", + ) + + async def _async_init_reauth( + self, + hass: HomeAssistant, + context: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + ) -> None: + """Start a reauth flow.""" + async with self._reauth_lock: + if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): + # Reauth flow already in progress for this entry + return + await hass.config_entries.flow.async_init( self.domain, context={ "source": SOURCE_REAUTH, @@ -753,9 +761,7 @@ class ConfigEntry: } | (context or {}), data=self.data | (data or {}), - ), - f"config entry reauth {self.title} {self.domain} {self.entry_id}", - ) + ) @callback def async_get_active_flows( @@ -765,7 +771,9 @@ class ConfigEntry: return ( flow for flow in hass.config_entries.flow.async_progress_by_handler( - self.domain, match_context={"entry_id": self.entry_id} + self.domain, + match_context={"entry_id": self.entry_id}, + include_uninitialized=True, ) if flow["context"].get("source") in sources ) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6756b8f3dc7..28241ef15f4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,8 +6,8 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "5" +MINOR_VERSION: Final = 11 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) @@ -55,6 +55,7 @@ class Platform(StrEnum): SWITCH = "switch" TEXT = "text" TIME = "time" + TODO = "todo" TTS = "tts" VACUUM = "vacuum" UPDATE = "update" @@ -293,6 +294,13 @@ EVENT_SERVICE_REGISTERED: Final = "service_registered" EVENT_SERVICE_REMOVED: Final = "service_removed" EVENT_STATE_CHANGED: Final = "state_changed" EVENT_THEMES_UPDATED: Final = "themes_updated" +EVENT_PANELS_UPDATED: Final = "panels_updated" +EVENT_LOVELACE_UPDATED: Final = "lovelace_updated" +EVENT_RECORDER_5MIN_STATISTICS_GENERATED: Final = "recorder_5min_statistics_generated" +EVENT_RECORDER_HOURLY_STATISTICS_GENERATED: Final = ( + "recorder_hourly_statistics_generated" +) +EVENT_SHOPPING_LIST_UPDATED: Final = "shopping_list_updated" # #### DEVICE CLASSES #### # DEVICE_CLASS_* below are deprecated as of 2021.12 @@ -1051,9 +1059,6 @@ COMPRESSED_STATE_LAST_CHANGED = "lc" COMPRESSED_STATE_LAST_UPDATED = "lu" # #### SERVICES #### -SERVICE_HOMEASSISTANT_STOP: Final = "stop" -SERVICE_HOMEASSISTANT_RESTART: Final = "restart" - SERVICE_TURN_ON: Final = "turn_on" SERVICE_TURN_OFF: Final = "turn_off" SERVICE_TOGGLE: Final = "toggle" diff --git a/homeassistant/core.py b/homeassistant/core.py index a50d43c1344..2025d813be4 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -28,7 +28,17 @@ import re import threading import time from time import monotonic -from typing import TYPE_CHECKING, Any, Generic, ParamSpec, Self, TypeVar, cast, overload +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Literal, + ParamSpec, + Self, + TypeVar, + cast, + overload, +) from urllib.parse import urlparse import voluptuous as vol @@ -222,6 +232,19 @@ def async_get_hass() -> HomeAssistant: return _hass.hass +@callback +def get_release_channel() -> Literal["beta", "dev", "nightly", "stable"]: + """Find release channel based on version number.""" + version = __version__ + if "dev0" in version: + return "dev" + if "dev" in version: + return "nightly" + if "b" in version: + return "beta" + return "stable" + + @enum.unique class HassJobType(enum.Enum): """Represent a job type.""" @@ -2106,12 +2129,15 @@ class Config: # Dictionary of Media folders that integrations may use self.media_dirs: dict[str, str] = {} - # If Home Assistant is running in safe mode - self.safe_mode: bool = False + # If Home Assistant is running in recovery mode + self.recovery_mode: bool = False # Use legacy template behavior self.legacy_templates: bool = False + # If Home Assistant is running in safe mode + self.safe_mode: bool = False + def distance(self, lat: float, lon: float) -> float | None: """Calculate distance from Home Assistant. @@ -2185,13 +2211,14 @@ class Config: "allowlist_external_urls": self.allowlist_external_urls, "version": __version__, "config_source": self.config_source, - "safe_mode": self.safe_mode, + "recovery_mode": self.recovery_mode, "state": self.hass.state.value, "external_url": self.external_url, "internal_url": self.internal_url, "currency": self.currency, "country": self.country, "language": self.language, + "safe_mode": self.safe_mode, } def set_time_zone(self, time_zone_str: str) -> None: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e22d4229511..e0ea195a3ff 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -382,14 +382,9 @@ class FlowManager(abc.ABC): self, flow: FlowHandler, step_id: str, user_input: dict | BaseServiceInfo | None ) -> FlowResult: """Handle a step of a flow.""" + self._raise_if_step_does_not_exist(flow, step_id) + method = f"async_step_{step_id}" - - if not hasattr(flow, method): - self._async_remove_flow_progress(flow.flow_id) - raise UnknownStep( - f"Handler {flow.__class__.__name__} doesn't support step {step_id}" - ) - try: result: FlowResult = await getattr(flow, method)(user_input) except AbortFlow as err: @@ -419,6 +414,7 @@ class FlowManager(abc.ABC): FlowResultType.SHOW_PROGRESS_DONE, FlowResultType.MENU, ): + self._raise_if_step_does_not_exist(flow, result["step_id"]) flow.cur_step = result return result @@ -435,6 +431,16 @@ class FlowManager(abc.ABC): return result + def _raise_if_step_does_not_exist(self, flow: FlowHandler, step_id: str) -> None: + """Raise if the step does not exist.""" + method = f"async_step_{step_id}" + + if not hasattr(flow, method): + self._async_remove_flow_progress(flow.flow_id) + raise UnknownStep( + f"Handler {self.__class__.__name__} doesn't support step {step_id}" + ) + async def _async_setup_preview(self, flow: FlowHandler) -> None: """Set up preview for a flow handler.""" if flow.handler not in self._preview: @@ -468,12 +474,12 @@ class FlowHandler: @property def source(self) -> str | None: """Source that initialized the flow.""" - return self.context.get("source", None) + return self.context.get("source", None) # type: ignore[no-any-return] @property def show_advanced_options(self) -> bool: """If we should show advanced options.""" - return self.context.get("show_advanced_options", False) + return self.context.get("show_advanced_options", False) # type: ignore[no-any-return] def add_suggested_values_to_schema( self, data_schema: vol.Schema, suggested_values: Mapping[str, Any] | None diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 8c9e3a57ddc..060080517bf 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -5,11 +5,13 @@ To update, run python3 -m script.hassfest APPLICATION_CREDENTIALS = [ "electric_kiwi", + "fitbit", "geocaching", "google", "google_assistant_sdk", "google_mail", "google_sheets", + "google_tasks", "home_connect", "lametric", "lyric", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index c2b24b68d29..13700a4521c 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -217,6 +217,11 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "idasen_desk", "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a", }, + { + "domain": "improv_ble", + "service_data_uuid": "00004677-0000-1000-8000-00805f9b34fb", + "service_uuid": "00467768-6228-2272-4663-277478268000", + }, { "connectable": False, "domain": "inkbird", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 29d067657b5..48864fef3af 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = { "group", "integration", "min_max", + "random", "switch_as_x", "template", "threshold", @@ -121,7 +122,6 @@ FLOWS = { "ecowitt", "edl21", "efergy", - "eight_sleep", "electrasmart", "electric_kiwi", "elgato", @@ -143,6 +143,7 @@ FLOWS = { "fibaro", "filesize", "fireservicerota", + "fitbit", "fivem", "fjaraskupan", "flick_electric", @@ -181,6 +182,7 @@ FLOWS = { "google_generative_ai_conversation", "google_mail", "google_sheets", + "google_tasks", "google_translate", "google_travel_time", "govee_ble", @@ -217,6 +219,7 @@ FLOWS = { "idasen_desk", "ifttt", "imap", + "improv_ble", "inkbird", "insteon", "intellifire", @@ -261,6 +264,7 @@ FLOWS = { "livisi", "local_calendar", "local_ip", + "local_todo", "locative", "logi_circle", "lookin", @@ -469,6 +473,7 @@ FLOWS = { "system_bridge", "tado", "tailscale", + "tami4", "tankerkoenig", "tasmota", "tautulli", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 91a02ac3e06..bc73c1b9804 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -784,6 +784,16 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "k[lps]*", "macaddress": "788CB5*", }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "3460F9*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "1C61B4*", + }, { "domain": "tuya", "macaddress": "105A17*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3a8ffea866d..f834f71bb07 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -946,6 +946,11 @@ "config_flow": true, "iot_class": "local_push" }, + "cribl": { + "name": "Cribl", + "integration_type": "virtual", + "supported_by": "splunk" + }, "crownstone": { "name": "Crownstone", "integration_type": "hub", @@ -1281,6 +1286,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "eastron": { + "name": "Eastron", + "integration_type": "virtual", + "supported_by": "homewizard" + }, "easyenergy": { "name": "easyEnergy", "integration_type": "hub", @@ -1365,12 +1375,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "eight_sleep": { - "name": "Eight Sleep", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "electrasmart": { "name": "Electra Smart", "integration_type": "hub", @@ -1733,7 +1737,7 @@ "fitbit": { "name": "Fitbit", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "fivem": { @@ -2152,6 +2156,12 @@ "iot_class": "cloud_polling", "name": "Google Sheets" }, + "google_tasks": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Tasks" + }, "google_translate": { "integration_type": "hub", "config_flow": true, @@ -2604,11 +2614,11 @@ "config_flow": true, "iot_class": "cloud_push" }, - "imap_email_content": { - "name": "IMAP Email Content", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" + "improv_ble": { + "name": "Improv via BLE", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" }, "incomfort": { "name": "Intergas InComfort/Intouch Lan2RF gateway", @@ -3101,6 +3111,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "local_todo": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "locative": { "name": "Locative", "integration_type": "hub", @@ -4342,6 +4357,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "portlandgeneral": { + "name": "Portland General Electric (PGE)", + "integration_type": "virtual", + "supported_by": "opower" + }, "private_ble_device": { "name": "Private BLE Device", "integration_type": "hub", @@ -4581,12 +4601,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "random": { - "name": "Random", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "rapt_ble": { "name": "RAPT Bluetooth", "integration_type": "hub", @@ -5040,12 +5054,6 @@ "config_flow": true, "iot_class": "local_push" }, - "shiftr": { - "name": "shiftr.io", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" - }, "shodan": { "name": "Shodan", "integration_type": "hub", @@ -5493,7 +5501,7 @@ "iot_class": "local_polling" }, "supla": { - "name": "Supla", + "name": "SUPLA", "integration_type": "hub", "config_flow": false, "iot_class": "cloud_polling" @@ -5620,6 +5628,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "tami4": { + "name": "Tami4 Edge / Edge+", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tank_utility": { "name": "Tank Utility", "integration_type": "hub", @@ -6019,7 +6033,7 @@ "iot_class": "cloud_polling" }, "twitter": { - "name": "Twitter", + "name": "X", "integration_type": "hub", "config_flow": false, "iot_class": "cloud_push" @@ -6397,7 +6411,7 @@ "name": "Withings", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_push" }, "wiz": { "name": "WiZ", @@ -6754,6 +6768,12 @@ "config_flow": true, "iot_class": "calculated" }, + "random": { + "name": "Random", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "schedule": { "integration_type": "helper", "config_flow": false @@ -6816,6 +6836,7 @@ "islamic_prayer_times", "local_calendar", "local_ip", + "local_todo", "min_max", "mobile_app", "moehlenhoff_alpha2", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index ac253d49254..b8d810d899b 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -7,7 +7,7 @@ from contextlib import suppress from ssl import SSLContext import sys from types import MappingProxyType -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import aiohttp from aiohttp import web @@ -29,9 +29,8 @@ if TYPE_CHECKING: DATA_CONNECTOR = "aiohttp_connector" -DATA_CONNECTOR_NOTVERIFY = "aiohttp_connector_notverify" DATA_CLIENTSESSION = "aiohttp_clientsession" -DATA_CLIENTSESSION_NOTVERIFY = "aiohttp_clientsession_notverify" + SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( APPLICATION_NAME, __version__, aiohttp.__version__, sys.version_info ) @@ -59,6 +58,19 @@ MAXIMUM_CONNECTIONS = 4096 MAXIMUM_CONNECTIONS_PER_HOST = 100 +# Overwrite base aiohttp _wait implementation +# Homeassistant has a custom shutdown wait logic. +async def _noop_wait(*args: Any, **kwargs: Any) -> None: + """Do nothing.""" + return + + +# TODO: Remove version check with aiohttp 3.9.0 # pylint: disable=fixme +if sys.version_info >= (3, 12): + # pylint: disable-next=protected-access + web.BaseSite._wait = _noop_wait # type: ignore[method-assign] + + class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -75,22 +87,31 @@ class HassClientResponse(aiohttp.ClientResponse): @callback @bind_hass def async_get_clientsession( - hass: HomeAssistant, verify_ssl: bool = True + hass: HomeAssistant, verify_ssl: bool = True, family: int = 0 ) -> aiohttp.ClientSession: """Return default aiohttp ClientSession. This method must be run in the event loop. """ - key = DATA_CLIENTSESSION if verify_ssl else DATA_CLIENTSESSION_NOTVERIFY + session_key = _make_key(verify_ssl, family) + if DATA_CLIENTSESSION not in hass.data: + sessions: dict[tuple[bool, int], aiohttp.ClientSession] = {} + hass.data[DATA_CLIENTSESSION] = sessions + else: + sessions = hass.data[DATA_CLIENTSESSION] - if key not in hass.data: - hass.data[key] = _async_create_clientsession( + if session_key not in sessions: + session = _async_create_clientsession( hass, verify_ssl, auto_cleanup_method=_async_register_default_clientsession_shutdown, + family=family, ) + sessions[session_key] = session + else: + session = sessions[session_key] - return cast(aiohttp.ClientSession, hass.data[key]) + return session @callback @@ -99,6 +120,7 @@ def async_create_clientsession( hass: HomeAssistant, verify_ssl: bool = True, auto_cleanup: bool = True, + family: int = 0, **kwargs: Any, ) -> aiohttp.ClientSession: """Create a new ClientSession with kwargs, i.e. for cookies. @@ -118,6 +140,7 @@ def async_create_clientsession( hass, verify_ssl, auto_cleanup_method=auto_cleanup_method, + family=family, **kwargs, ) @@ -130,11 +153,12 @@ def _async_create_clientsession( verify_ssl: bool = True, auto_cleanup_method: Callable[[HomeAssistant, aiohttp.ClientSession], None] | None = None, + family: int = 0, **kwargs: Any, ) -> aiohttp.ClientSession: """Create a new ClientSession with kwargs, i.e. for cookies.""" clientsession = aiohttp.ClientSession( - connector=_async_get_connector(hass, verify_ssl), + connector=_async_get_connector(hass, verify_ssl, family), json_serialize=json_dumps, response_class=HassClientResponse, **kwargs, @@ -262,18 +286,29 @@ def _async_register_default_clientsession_shutdown( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_websession) +@callback +def _make_key(verify_ssl: bool = True, family: int = 0) -> tuple[bool, int]: + """Make a key for connector or session pool.""" + return (verify_ssl, family) + + @callback def _async_get_connector( - hass: HomeAssistant, verify_ssl: bool = True + hass: HomeAssistant, verify_ssl: bool = True, family: int = 0 ) -> aiohttp.BaseConnector: """Return the connector pool for aiohttp. This method must be run in the event loop. """ - key = DATA_CONNECTOR if verify_ssl else DATA_CONNECTOR_NOTVERIFY + connector_key = _make_key(verify_ssl, family) + if DATA_CONNECTOR not in hass.data: + connectors: dict[tuple[bool, int], aiohttp.BaseConnector] = {} + hass.data[DATA_CONNECTOR] = connectors + else: + connectors = hass.data[DATA_CONNECTOR] - if key in hass.data: - return cast(aiohttp.BaseConnector, hass.data[key]) + if connector_key in connectors: + return connectors[connector_key] if verify_ssl: ssl_context: bool | SSLContext = ssl_util.get_default_context() @@ -281,12 +316,13 @@ def _async_get_connector( ssl_context = ssl_util.get_default_no_verify_context() connector = aiohttp.TCPConnector( + family=family, enable_cleanup_closed=ENABLE_CLEANUP_CLOSED, ssl=ssl_context, limit=MAXIMUM_CONNECTIONS, limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, ) - hass.data[key] = connector + connectors[connector_key] = connector async def _async_close_connector(event: Event) -> None: """Close connector pool.""" diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 1e1cac050f1..a5e68cb877d 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -127,7 +127,7 @@ async def async_check_ha_config_file( # noqa: C901 try: integration = await async_get_integration_with_requirements(hass, domain) except loader.IntegrationNotFound as ex: - if not hass.config.safe_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Integration error: {domain} - {ex}") continue except RequirementsNotFound as ex: @@ -216,7 +216,7 @@ async def async_check_ha_config_file( # noqa: C901 ) platform = p_integration.get_platform(domain) except loader.IntegrationNotFound as ex: - if not hass.config.safe_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Platform error {domain}.{p_name} - {ex}") continue except ( diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 16a4fad5d0c..1a106364566 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -72,7 +72,8 @@ class AbstractOAuth2Implementation(ABC): Pass external data in with: await hass.config_entries.flow.async_configure( - flow_id=flow_id, user_input={'code': 'abcd', 'state': { … } + flow_id=flow_id, user_input={'code': 'abcd', 'state': … } + ) """ diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index a4018101d0e..18445ba0789 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -203,12 +203,9 @@ def boolean(value: Any) -> bool: raise vol.Invalid(f"invalid boolean value {value}") -_WS = re.compile("\\s*") - - def whitespace(value: Any) -> str: """Validate result contains only whitespace.""" - if isinstance(value, str) and _WS.fullmatch(value): + if isinstance(value, str) and (value == "" or value.isspace()): return value raise vol.Invalid(f"contains non-whitespace: {value}") @@ -599,7 +596,7 @@ def string(value: Any) -> str: def string_with_no_html(value: Any) -> str: """Validate that the value is a string without HTML.""" value = string(value) - regex = re.compile(r"<[a-z][\s\S]*>") + regex = re.compile(r"<[a-z].*?>", re.IGNORECASE) if regex.search(value): raise vol.Invalid("the string should not contain HTML") return str(value) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 08803aaded6..c499dd0b6cd 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -2,12 +2,17 @@ from __future__ import annotations from collections.abc import Callable +from contextlib import suppress import functools import inspect import logging from typing import Any, ParamSpec, TypeVar -from ..helpers.frame import MissingIntegrationFrame, get_integration_frame +from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import async_suggest_report_issue + +from .frame import MissingIntegrationFrame, get_integration_frame _ObjectT = TypeVar("_ObjectT", bound=object) _R = TypeVar("_R") @@ -132,24 +137,32 @@ def deprecated_function( def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: logger = logging.getLogger(obj.__module__) try: - _, integration, path = get_integration_frame() - if path == "custom_components/": + integration_frame = get_integration_frame() + if integration_frame.custom_integration: + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + report_issue = async_suggest_report_issue( + hass, + integration_domain=integration_frame.integration, + module=integration_frame.module, + ) logger.warning( ( "%s was called from %s, this is a deprecated %s. Use %s instead," - " please report this to the maintainer of %s" + " please %s" ), obj.__name__, - integration, + integration_frame.integration, description, replacement, - integration, + report_issue, ) else: logger.warning( "%s was called from %s, this is a deprecated %s. Use %s instead", obj.__name__, - integration, + integration_frame.integration, description, replacement, ) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 064579a95d3..48ebd7b6ebc 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -36,7 +36,7 @@ DATA_REGISTRY = "device_registry" EVENT_DEVICE_REGISTRY_UPDATED = "device_registry_updated" STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 3 +STORAGE_VERSION_MINOR = 4 SAVE_DELAY = 10 CLEANUP_DELAY = 10 @@ -79,6 +79,7 @@ class DeviceInfo(TypedDict, total=False): manufacturer: str | None model: str | None name: str | None + serial_number: str | None suggested_area: str | None sw_version: str | None hw_version: str | None @@ -102,6 +103,7 @@ DEVICE_INFO_TYPES = { "manufacturer", "model", "name", + "serial_number", "suggested_area", "sw_version", "via_device", @@ -229,6 +231,7 @@ class DeviceEntry: model: str | None = attr.ib(default=None) name_by_user: str | None = attr.ib(default=None) name: str | None = attr.ib(default=None) + serial_number: str | None = attr.ib(default=None) suggested_area: str | None = attr.ib(default=None) sw_version: str | None = attr.ib(default=None) via_device_id: str | None = attr.ib(default=None) @@ -257,6 +260,7 @@ class DeviceEntry: "model": self.model, "name_by_user": self.name_by_user, "name": self.name, + "serial_number": self.serial_number, "sw_version": self.sw_version, "via_device_id": self.via_device_id, } @@ -359,6 +363,10 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): # Version 1.3 adds hw_version for device in old_data["devices"]: device["hw_version"] = None + if old_minor_version < 4: + # Introduced in 2023.11 + for device in old_data["devices"]: + device["serial_number"] = None if old_major_version > 1: raise NotImplementedError @@ -490,6 +498,7 @@ class DeviceRegistry: manufacturer: str | None | UndefinedType = UNDEFINED, model: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + serial_number: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, @@ -514,6 +523,7 @@ class DeviceRegistry: ("manufacturer", manufacturer), ("model", model), ("name", name), + ("serial_number", serial_number), ("suggested_area", suggested_area), ("sw_version", sw_version), ("via_device", via_device), @@ -591,6 +601,7 @@ class DeviceRegistry: merge_identifiers=identifiers or UNDEFINED, model=model, name=name, + serial_number=serial_number, suggested_area=suggested_area, sw_version=sw_version, via_device_id=via_device_id, @@ -620,6 +631,7 @@ class DeviceRegistry: name: str | None | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, + serial_number: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, @@ -709,6 +721,7 @@ class DeviceRegistry: ("model", model), ("name", name), ("name_by_user", name_by_user), + ("serial_number", serial_number), ("suggested_area", suggested_area), ("sw_version", sw_version), ("via_device_id", via_device_id), @@ -802,6 +815,7 @@ class DeviceRegistry: model=device["model"], name_by_user=device["name_by_user"], name=device["name"], + serial_number=device["serial_number"], sw_version=device["sw_version"], via_device_id=device["via_device_id"], ) @@ -851,6 +865,7 @@ class DeviceRegistry: "model": entry.model, "name_by_user": entry.name_by_user, "name": entry.name, + "serial_number": entry.serial_number, "sw_version": entry.sw_version, "via_device_id": entry.via_device_id, } diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 306e8b51d63..c2c9a04b7c3 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -8,7 +8,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.loader import bind_hass -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency FLOW_INIT_LIMIT = 2 DISCOVERY_FLOW_DISPATCHER = "discovery_flow_dispatcher" @@ -93,7 +93,7 @@ class FlowDispatcher: for flow_key, flows in pending_flows.items() for flow_values in flows ] - await gather_with_concurrency( + await gather_with_limited_concurrency( FLOW_INIT_LIMIT, *[init_coro for init_coro in init_coros if init_coro is not None], ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9b16b0c24fd..1bc8f0b308b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,7 +4,6 @@ from __future__ import annotations from abc import ABC import asyncio from collections.abc import Coroutine, Iterable, Mapping, MutableMapping -from contextlib import suppress from dataclasses import dataclass from datetime import timedelta from enum import Enum, auto @@ -50,11 +49,7 @@ from homeassistant.exceptions import ( InvalidStateError, NoEntitySpecifiedError, ) -from homeassistant.loader import ( - IntegrationNotLoaded, - async_get_loaded_integration, - bind_hass, -) +from homeassistant.loader import async_suggest_report_issue, bind_hass from homeassistant.util import ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er @@ -166,7 +161,7 @@ def get_supported_features(hass: HomeAssistant, entity_id: str) -> int: First try the statemachine, then entity registry. """ if state := hass.states.get(entity_id): - return state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + return state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # type: ignore[no-any-return] entity_registry = er.async_get(hass) if not (entry := entity_registry.async_get(entity_id)): @@ -273,9 +268,6 @@ class Entity(ABC): # it should be using async_write_ha_state. _async_update_ha_state_reported = False - # If we reported this entity is implicitly using device name - _implicit_device_name_reported = False - # If we reported this entity was added without its platform set _no_platform_reported = False @@ -363,22 +355,6 @@ class Entity(ABC): """Return a unique ID.""" return self._attr_unique_id - def _report_implicit_device_name(self) -> None: - """Report entities which use implicit device name.""" - if self._implicit_device_name_reported: - return - report_issue = self._suggest_report_issue() - _LOGGER.warning( - ( - "Entity %s (%s) is implicitly using device name by not setting its " - "name. Instead, the name should be set to None, please %s" - ), - self.entity_id, - type(self), - report_issue, - ) - self._implicit_device_name_reported = True - @property def use_device_name(self) -> bool: """Return if this entity does not have its own name. @@ -393,20 +369,8 @@ class Entity(ABC): return False if hasattr(self, "entity_description"): - if not (name := self.entity_description.name): - return True - if name is UNDEFINED and not self._default_to_device_class_name(): - # Backwards compatibility with leaving EntityDescription.name unassigned - # for device name. - # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 - self._report_implicit_device_name() - return True - return False - if self.name is UNDEFINED and not self._default_to_device_class_name(): - # Backwards compatibility with not overriding name property for device name. - # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 - self._report_implicit_device_name() - return True + return not self.entity_description.name + return not self.name @property @@ -912,7 +876,9 @@ class Entity(ABC): self._state_info, ) except InvalidStateError: - _LOGGER.exception("Failed to set state, fall back to %s", STATE_UNKNOWN) + _LOGGER.exception( + "Failed to set state for %s, fall back to %s", entity_id, STATE_UNKNOWN + ) hass.states.async_set( entity_id, STATE_UNKNOWN, {}, self.force_update, self._context ) @@ -1257,35 +1223,12 @@ class Entity(ABC): def _suggest_report_issue(self) -> str: """Suggest to report an issue.""" - report_issue = "" - - integration = None # The check for self.platform guards against integrations not using an # EntityComponent and can be removed in HA Core 2024.1 - if self.platform: - with suppress(IntegrationNotLoaded): - integration = async_get_loaded_integration( - self.hass, self.platform.platform_name - ) - - if "custom_components" in type(self).__module__: - if integration and integration.issue_tracker: - report_issue = f"create a bug report at {integration.issue_tracker}" - else: - 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" - ) - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if self.platform: - report_issue += ( - f"+label%3A%22integration%3A+{self.platform.platform_name}%22" - ) - - return report_issue + platform_name = self.platform.platform_name if self.platform else None + return async_suggest_report_issue( + self.hass, integration_domain=platform_name, module=type(self).__module__ + ) @dataclass(slots=True) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 42de4749215..a97e283af07 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -65,7 +65,7 @@ SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 11 +STORAGE_VERSION_MINOR = 12 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -156,6 +156,7 @@ class RegistryEntry: entity_id: str = attr.ib() unique_id: str = attr.ib() platform: str = attr.ib() + previous_unique_id: str | None = attr.ib(default=None) aliases: set[str] = attr.ib(factory=set) area_id: str | None = attr.ib(default=None) capabilities: Mapping[str, Any] | None = attr.ib(default=None) @@ -246,7 +247,7 @@ class RegistryEntry: return None - @property + @cached_property def as_partial_dict(self) -> dict[str, Any]: """Return a partial dict representation of the entry.""" return { @@ -268,6 +269,18 @@ class RegistryEntry: "unique_id": self.unique_id, } + @cached_property + def extended_dict(self) -> dict[str, Any]: + """Return a extended dict representation of the entry.""" + return { + **self.as_partial_dict, + "aliases": self.aliases, + "capabilities": self.capabilities, + "device_class": self.device_class, + "original_device_class": self.original_device_class, + "original_icon": self.original_icon, + } + @cached_property def partial_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry.""" @@ -410,6 +423,11 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): # Version 1.11 adds deleted_entities data["deleted_entities"] = data.get("deleted_entities", []) + if old_major_version == 1 and old_minor_version < 12: + # Version 1.12 adds previous_unique_id + for entity in data["entities"]: + entity["previous_unique_id"] = None + if old_major_version > 1: raise NotImplementedError return data @@ -893,6 +911,7 @@ class EntityRegistry: ) new_values["unique_id"] = new_unique_id old_values["unique_id"] = old.unique_id + new_values["previous_unique_id"] = old.unique_id if not new_values: return old @@ -1060,6 +1079,7 @@ class EntityRegistry: supported_features=entity["supported_features"], translation_key=entity["translation_key"], unique_id=entity["unique_id"], + previous_unique_id=entity["previous_unique_id"], unit_of_measurement=entity["unit_of_measurement"], ) for entity in data["deleted_entities"]: @@ -1115,6 +1135,7 @@ class EntityRegistry: "supported_features": entry.supported_features, "translation_key": entry.translation_key, "unique_id": entry.unique_id, + "previous_unique_id": entry.previous_unique_id, "unit_of_measurement": entry.unit_of_measurement, } for entry in self.entities.values() @@ -1323,12 +1344,18 @@ async def async_migrate_entries( config_entry_id: str, entry_callback: Callable[[RegistryEntry], dict[str, Any] | None], ) -> None: - """Migrator of unique IDs.""" + """Migrate entity registry entries which belong to a config entry. + + Can be used as a migrator of unique_ids or to update other entity registry data. + Can also be used to remove duplicated entity registry entries. + """ ent_reg = async_get(hass) - for entry in ent_reg.entities.values(): + for entry in list(ent_reg.entities.values()): if entry.config_entry_id != config_entry_id: continue + if not ent_reg.entities.get_entry(entry.id): + continue updates = entry_callback(entry) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 988db411a6b..920c7150f6d 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -3,12 +3,17 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass import functools import logging +import sys from traceback import FrameSummary, extract_stack from typing import Any, TypeVar, cast +from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import async_suggest_report_issue _LOGGER = logging.getLogger(__name__) @@ -18,9 +23,18 @@ _REPORTED_INTEGRATIONS: set[str] = set() _CallableT = TypeVar("_CallableT", bound=Callable) -def get_integration_frame( - exclude_integrations: set | None = None, -) -> tuple[FrameSummary, str, str]: +@dataclass(kw_only=True) +class IntegrationFrame: + """Integration frame container.""" + + custom_integration: bool + frame: FrameSummary + integration: str + module: str | None + relative_filename: str + + +def get_integration_frame(exclude_integrations: set | None = None) -> IntegrationFrame: """Return the frame, integration and integration path of the current stack frame.""" found_frame = None if not exclude_integrations: @@ -46,7 +60,21 @@ def get_integration_frame( if found_frame is None: raise MissingIntegrationFrame - return found_frame, integration, path + found_module: str | None = None + for module, module_obj in dict(sys.modules).items(): + if not hasattr(module_obj, "__file__"): + continue + if module_obj.__file__ == found_frame.filename: + found_module = module + break + + return IntegrationFrame( + custom_integration=path == "custom_components/", + frame=found_frame, + integration=integration, + module=found_module, + relative_filename=found_frame.filename[index:], + ) class MissingIntegrationFrame(HomeAssistantError): @@ -74,44 +102,44 @@ def report( _LOGGER.warning(msg, stack_info=True) return - report_integration(what, integration_frame, level) + _report_integration(what, integration_frame, level) -def report_integration( +def _report_integration( what: str, - integration_frame: tuple[FrameSummary, str, str], + integration_frame: IntegrationFrame, level: int = logging.WARNING, ) -> None: """Report incorrect usage in an integration. Async friendly. """ - found_frame, integration, path = integration_frame - + found_frame = integration_frame.frame # Keep track of integrations already reported to prevent flooding key = f"{found_frame.filename}:{found_frame.lineno}" if key in _REPORTED_INTEGRATIONS: return _REPORTED_INTEGRATIONS.add(key) - index = found_frame.filename.index(path) - if path == "custom_components/": - extra = " to the custom integration author" - else: - extra = "" + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + report_issue = async_suggest_report_issue( + hass, + integration_domain=integration_frame.integration, + module=integration_frame.module, + ) _LOGGER.log( level, - ( - "Detected integration that %s. " - "Please report issue%s for %s using this method at %s, line %s: %s" - ), + "Detected that %sintegration '%s' %s at %s, line %s: %s, please %s", + "custom " if integration_frame.custom_integration else "", + integration_frame.integration, what, - extra, - integration, - found_frame.filename[index:], + integration_frame.relative_filename, found_frame.lineno, (found_frame.line or "?").strip(), + report_issue, ) diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index a289ab4a874..97e0d20927c 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -1,7 +1,10 @@ """Icon helper methods.""" from __future__ import annotations +from functools import lru_cache + +@lru_cache def icon_for_battery_level( battery_level: int | None = None, charging: bool = False ) -> str: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index f27cea8dd1e..056f972e7f7 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -31,6 +31,7 @@ INTENT_TURN_OFF = "HassTurnOff" INTENT_TURN_ON = "HassTurnOn" INTENT_TOGGLE = "HassToggle" INTENT_GET_STATE = "HassGetState" +INTENT_NEVERMIND = "HassNevermind" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index e94093cfd2f..e155427fa10 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -115,7 +115,7 @@ def json_bytes_strip_null(data: Any) -> bytes: def json_dumps(data: Any) -> str: - """Dump json string. + r"""Dump json string. orjson supports serializing dataclasses natively which eliminates the need to implement as_dict in many places @@ -124,7 +124,7 @@ def json_dumps(data: Any) -> str: be serialized. If it turns out to be a problem we can disable this - with option |= orjson.OPT_PASSTHROUGH_DATACLASS and it + with option \|= orjson.OPT_PASSTHROUGH_DATACLASS and it will fallback to as_dict """ return json_bytes(data).decode("utf-8") diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 20a5d8de5a8..dcf7f07bf6b 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -171,13 +171,37 @@ class SchemaCommonFlowHandler: if user_input is not None: # User input was validated successfully, update options - self._options.update(user_input) + self._update_and_remove_omitted_optional_keys( + self._options, user_input, data_schema + ) if user_input is not None or form_step.schema is None: return await self._show_next_step_or_create_entry(form_step) return await self._show_next_step(step_id) + def _update_and_remove_omitted_optional_keys( + self, + values: dict[str, Any], + user_input: dict[str, Any], + data_schema: vol.Schema | None, + ) -> None: + values.update(user_input) + if data_schema and data_schema.schema: + for key in data_schema.schema: + if ( + isinstance(key, vol.Optional) + and key not in user_input + and not ( + # don't remove advanced keys, if they are hidden + key.description + and key.description.get("advanced") + and not self._handler.show_advanced_options + ) + ): + # Key not present, delete keys old value (if present) too + values.pop(key, None) + async def _show_next_step_or_create_entry( self, form_step: SchemaFlowFormStep ) -> FlowResult: @@ -221,7 +245,9 @@ class SchemaCommonFlowHandler: if user_input: # We don't want to mutate the existing options suggested_values = copy.deepcopy(suggested_values) - suggested_values.update(user_input) + self._update_and_remove_omitted_optional_keys( + suggested_values, user_input, await self._get_schema(form_step) + ) if data_schema.schema: # Make a copy of the schema with suggested values set to saved options diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index efb1ee0b1f1..51a54b3988f 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_MODE, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import split_entity_id, valid_entity_id +from homeassistant.generated.countries import COUNTRIES from homeassistant.util import decorator from homeassistant.util.yaml import dumper @@ -98,6 +99,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.remote import RemoteEntityFeature from homeassistant.components.siren import SirenEntityFeature + from homeassistant.components.todo import TodoListEntityFeature from homeassistant.components.update import UpdateEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.components.water_heater import WaterHeaterEntityFeature @@ -117,6 +119,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: "MediaPlayerEntityFeature": MediaPlayerEntityFeature, "RemoteEntityFeature": RemoteEntityFeature, "SirenEntityFeature": SirenEntityFeature, + "TodoListEntityFeature": TodoListEntityFeature, "UpdateEntityFeature": UpdateEntityFeature, "VacuumEntityFeature": VacuumEntityFeature, "WaterHeaterEntityFeature": WaterHeaterEntityFeature, @@ -564,6 +567,40 @@ class ConversationAgentSelector(Selector[ConversationAgentSelectorConfig]): return agent +class CountrySelectorConfig(TypedDict, total=False): + """Class to represent a country selector config.""" + + countries: list[str] + no_sort: bool + + +@SELECTORS.register("country") +class CountrySelector(Selector[CountrySelectorConfig]): + """Selector for a single-choice country select.""" + + selector_type = "country" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("countries"): [str], + vol.Optional("no_sort", default=False): cv.boolean, + } + ) + + def __init__(self, config: CountrySelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + country: str = vol.Schema(str)(data) + if "countries" in self.config and ( + country not in self.config["countries"] or country not in COUNTRIES + ): + raise vol.Invalid(f"Value {country} is not a valid option") + return country + + class DateSelectorConfig(TypedDict): """Class to represent a date selector config.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index b0754c13c7c..06280a26ccd 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1956,6 +1956,41 @@ def is_number(value): return True +def _is_list(value: Any) -> bool: + """Return whether a value is a list.""" + return isinstance(value, list) + + +def _is_set(value: Any) -> bool: + """Return whether a value is a set.""" + return isinstance(value, set) + + +def _is_tuple(value: Any) -> bool: + """Return whether a value is a tuple.""" + return isinstance(value, tuple) + + +def _to_set(value: Any) -> set[Any]: + """Convert value to set.""" + return set(value) + + +def _to_tuple(value): + """Convert value to tuple.""" + return tuple(value) + + +def _is_datetime(value: Any) -> bool: + """Return whether a value is a datetime.""" + return isinstance(value, datetime) + + +def _is_string_like(value: Any) -> bool: + """Return whether a value is a string or string like object.""" + return isinstance(value, (str, bytes, bytearray)) + + def regex_match(value, find="", ignorecase=False): """Match value using regex.""" if not isinstance(value, str): @@ -2387,6 +2422,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["max"] = min_max_from_filter(self.filters["max"], "max") self.globals["min"] = min_max_from_filter(self.filters["min"], "min") self.globals["is_number"] = is_number + self.globals["set"] = _to_set + self.globals["tuple"] = _to_tuple self.globals["int"] = forgiving_int self.globals["pack"] = struct_pack self.globals["unpack"] = struct_unpack @@ -2395,6 +2432,11 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["bool"] = forgiving_boolean self.globals["version"] = version self.tests["is_number"] = is_number + self.tests["list"] = _is_list + self.tests["set"] = _is_set + self.tests["tuple"] = _is_tuple + self.tests["datetime"] = _is_datetime + self.tests["string_like"] = _is_string_like self.tests["match"] = regex_match self.tests["search"] = regex_search self.tests["contains"] = contains @@ -2513,7 +2555,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["expand"] = hassfunction(expand) self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = hassfunction(closest_filter) + self.filters["closest"] = hassfunction(closest_filter) # type: ignore[arg-type] self.globals["distance"] = hassfunction(distance) self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) self.tests["is_hidden_entity"] = hassfunction( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 9d4d6e880f8..39564846de3 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -188,7 +188,7 @@ async def _async_get_custom_components( hass: HomeAssistant, ) -> dict[str, Integration]: """Return list of custom integrations.""" - if hass.config.safe_mode: + if hass.config.recovery_mode or hass.config.safe_mode: return {} try: @@ -777,9 +777,7 @@ class Integration: return self._all_dependencies_resolved try: - dependencies = await _async_component_dependencies( - self.hass, self.domain, self, set(), set() - ) + dependencies = await _async_component_dependencies(self.hass, self) dependencies.discard(self.domain) self._all_dependencies = dependencies self._all_dependencies_resolved = True @@ -998,7 +996,7 @@ class IntegrationNotLoaded(LoaderError): class CircularDependency(LoaderError): """Raised when a circular dependency is found when resolving components.""" - def __init__(self, from_domain: str, to_domain: str) -> None: + def __init__(self, from_domain: str | set[str], to_domain: str) -> None: """Initialize circular dependency error.""" super().__init__(f"Circular dependency detected: {from_domain} -> {to_domain}.") self.from_domain = from_domain @@ -1132,43 +1130,40 @@ def bind_hass(func: _CallableT) -> _CallableT: async def _async_component_dependencies( hass: HomeAssistant, - start_domain: str, integration: Integration, - loaded: set[str], - loading: set[str], ) -> set[str]: - """Recursive function to get component dependencies. + """Get component dependencies.""" + loading = set() + loaded = set() - Async friendly. - """ - domain = integration.domain - loading.add(domain) + async def component_dependencies_impl(integration: Integration) -> None: + """Recursively get component dependencies.""" + domain = integration.domain + loading.add(domain) - for dependency_domain in integration.dependencies: - # Check not already loaded - if dependency_domain in loaded: - continue + for dependency_domain in integration.dependencies: + dep_integration = await async_get_integration(hass, dependency_domain) - # If we are already loading it, we have a circular dependency. - if dependency_domain in loading: - raise CircularDependency(domain, dependency_domain) + # If we are already loading it, we have a circular dependency. + # We have to check it here to make sure that every integration that + # depends on us, does not appear in our own after_dependencies. + if conflict := loading.intersection(dep_integration.after_dependencies): + raise CircularDependency(conflict, dependency_domain) - loaded.add(dependency_domain) + # If we have already loaded it, no point doing it again. + if dependency_domain in loaded: + continue - dep_integration = await async_get_integration(hass, dependency_domain) + # If we are already loading it, we have a circular dependency. + if dependency_domain in loading: + raise CircularDependency(dependency_domain, domain) - if start_domain in dep_integration.after_dependencies: - raise CircularDependency(start_domain, dependency_domain) + await component_dependencies_impl(dep_integration) - if dep_integration.dependencies: - dep_loaded = await _async_component_dependencies( - hass, start_domain, dep_integration, loaded, loading - ) + loading.remove(domain) + loaded.add(domain) - loaded.update(dep_loaded) - - loaded.add(domain) - loading.remove(domain) + await component_dependencies_impl(integration) return loaded @@ -1184,7 +1179,7 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: def _lookup_path(hass: HomeAssistant) -> list[str]: """Return the lookup paths for legacy lookups.""" - if hass.config.safe_mode: + if hass.config.recovery_mode or hass.config.safe_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] @@ -1192,3 +1187,55 @@ def _lookup_path(hass: HomeAssistant) -> list[str]: def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: """Test if a component module is loaded.""" return module in hass.data[DATA_COMPONENTS] + + +@callback +def async_get_issue_tracker( + hass: HomeAssistant | None, + *, + integration_domain: str | None = None, + module: str | None = None, +) -> str | None: + """Return a URL for an integration's issue tracker.""" + issue_tracker = ( + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + if not integration_domain and not module: + # If we know nothing about the entity, suggest opening an issue on HA core + return issue_tracker + + if hass and integration_domain: + with suppress(IntegrationNotLoaded): + integration = async_get_loaded_integration(hass, integration_domain) + if not integration.is_built_in: + return integration.issue_tracker + + if module and "custom_components" in module: + return None + + if integration_domain: + issue_tracker += f"+label%3A%22integration%3A+{integration_domain}%22" + return issue_tracker + + +@callback +def async_suggest_report_issue( + hass: HomeAssistant | None, + *, + integration_domain: str | None = None, + module: str | None = None, +) -> str: + """Generate a blurb asking the user to file a bug report.""" + issue_tracker = async_get_issue_tracker( + hass, integration_domain=integration_domain, module=module + ) + + if not issue_tracker: + if not integration_domain: + return "report it to the custom integration author" + return ( + f"report it to the author of the '{integration_domain}' " + "custom integration" + ) + + return f"create a bug report at {issue_tracker}" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9bbed054257..a70bcf4524a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,6 @@ aiodiscover==1.5.1 -aiohttp==3.8.5 +aiohttp==3.8.5;python_version<'3.12' +aiohttp==3.9.0b0;python_version>='3.12' aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.2 @@ -7,7 +8,7 @@ atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.8.0 bcrypt==4.0.1 -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 @@ -16,23 +17,23 @@ certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.4 dbus-fast==2.12.0 -fnv-hash-fast==0.4.1 +fnv-hash-fast==0.5.0 ha-av==10.1.1 -hass-nabucasa==0.71.0 +hass-nabucasa==0.74.0 hassil==1.2.5 -home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20231005.0 -home-assistant-intents==2023.10.2 -httpx==0.24.1 +home-assistant-bluetooth==1.10.4 +home-assistant-frontend==20231030.1 +home-assistant-intents==2023.10.16 +httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 lru-dict==1.2.0 mutagen==1.47.0 -orjson==3.9.7 +orjson==3.9.9 packaging>=23.1 paho-mqtt==1.6.1 -Pillow==10.0.1 +Pillow==10.1.0 pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.8.0 @@ -45,14 +46,14 @@ pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.21 +SQLAlchemy==2.0.22 typing-extensions>=4.8.0,<5.0 -ulid-transform==0.8.1 +ulid-transform==0.9.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.115.2 +zeroconf==0.119.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -70,9 +71,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.58.0 -grpcio-status==1.58.0 -grpcio-reflection==1.58.0 +grpcio==1.59.0 +grpcio-status==1.59.0 +grpcio-reflection==1.59.0 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, @@ -102,9 +103,9 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.7.1 +anyio==4.0.0 h11==0.14.0 -httpcore==0.17.3 +httpcore==0.18.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 954de3bf5a6..27a9607a6ee 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -85,11 +85,8 @@ def pip_kwargs(config_dir: str | None) -> dict[str, Any]: is_docker = pkg_util.is_docker_env() kwargs = { "constraints": os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE), - "no_cache_dir": is_docker, "timeout": PIP_TIMEOUT, } - if "WHEELS_LINKS" in os.environ: - kwargs["find_links"] = os.environ["WHEELS_LINKS"] if not (config_dir is None or pkg_util.is_virtual_env()) and not is_docker: kwargs["target"] = os.path.join(config_dir, "deps") return kwargs diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 10521f80135..622e69ecf8c 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -43,7 +43,7 @@ class RuntimeConfig: config_dir: str skip_pip: bool = False skip_pip_packages: list[str] = dataclasses.field(default_factory=list) - safe_mode: bool = False + recovery_mode: bool = False verbose: bool = False @@ -54,6 +54,8 @@ class RuntimeConfig: debug: bool = False open_ui: bool = False + safe_mode: bool = False + def can_use_pidfd() -> bool: """Check if pidfd_open is available. diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 871e1b4ecbc..f41380fc9e5 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -67,8 +67,8 @@ }, "config_flow": { "title": { - "oauth2_pick_implementation": "Pick Authentication Method", - "reauth": "Reauthenticate Integration", + "oauth2_pick_implementation": "Pick authentication method", + "reauth": "Reauthenticate integration", "via_hassio_addon": "{name} via Home Assistant add-on" }, "description": { @@ -81,20 +81,20 @@ "username": "Username", "password": "Password", "host": "Host", - "ip": "IP Address", + "ip": "IP address", "port": "Port", "url": "URL", - "usb_path": "USB Device Path", - "access_token": "Access Token", - "api_key": "API Key", - "api_token": "API Token", + "usb_path": "USB device path", + "access_token": "Access token", + "api_key": "API key", + "api_token": "API token", "ssl": "Uses an SSL certificate", "verify_ssl": "Verify SSL certificate", "elevation": "Elevation", "longitude": "Longitude", "latitude": "Latitude", "location": "Location", - "pin": "PIN Code", + "pin": "PIN code", "mode": "Mode", "path": "Path" }, diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index ce1105cff75..bcc7be62265 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -5,12 +5,15 @@ from asyncio import Future, Semaphore, gather, get_running_loop from asyncio.events import AbstractEventLoop from collections.abc import Awaitable, Callable import concurrent.futures +from contextlib import suppress import functools import logging import threading from traceback import extract_stack from typing import Any, ParamSpec, TypeVar +from homeassistant.exceptions import HomeAssistantError + _LOGGER = logging.getLogger(__name__) _SHUTDOWN_RUN_CALLBACK_THREADSAFE = "_shutdown_run_callback_threadsafe" @@ -82,6 +85,14 @@ def check_loop( The default advisory message is 'Use `await hass.async_add_executor_job()' Set `advise_msg` to an alternate message if the solution differs. """ + # pylint: disable=import-outside-toplevel + from homeassistant.core import HomeAssistant, async_get_hass + from homeassistant.helpers.frame import ( + MissingIntegrationFrame, + get_integration_frame, + ) + from homeassistant.loader import async_suggest_report_issue + try: get_running_loop() in_loop = True @@ -104,54 +115,47 @@ def check_loop( # stack[-1] is us, stack[-2] is protected_loop_func, stack[-3] is the offender return - for frame in reversed(stack): - for path in ("custom_components/", "homeassistant/components/"): - try: - index = frame.filename.index(path) - found_frame = frame - break - except ValueError: - continue + try: + integration_frame = get_integration_frame() + except MissingIntegrationFrame: + # Did not source from integration? Hard error. + if found_frame is None: + raise RuntimeError( # noqa: TRY200 + f"Detected blocking call to {func.__name__} inside the event loop. " + f"{advise_msg or 'Use `await hass.async_add_executor_job()`'}; " + "This is causing stability issues. Please create a bug report at " + f"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) - if found_frame is not None: - break - - # Did not source from integration? Hard error. - if found_frame is None: - raise RuntimeError( - f"Detected blocking call to {func.__name__} inside the event loop. " - f"{advise_msg or 'Use `await hass.async_add_executor_job()`'}; " - "This is causing stability issues. Please report issue" - ) - - start = index + len(path) - end = found_frame.filename.index("/", start) - - integration = found_frame.filename[start:end] - - if path == "custom_components/": - extra = " to the custom integration author" - else: - extra = "" + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + report_issue = async_suggest_report_issue( + hass, + integration_domain=integration_frame.integration, + module=integration_frame.module, + ) + found_frame = integration_frame.frame _LOGGER.warning( ( - "Detected blocking call to %s inside the event loop. This is causing" - " stability issues. Please report issue%s for %s doing blocking calls at" - " %s, line %s: %s" + "Detected blocking call to %s inside the event loop by %sintegration '%s' " + "at %s, line %s: %s, please %s" ), func.__name__, - extra, - integration, - found_frame.filename[index:], + "custom " if integration_frame.custom_integration else "", + integration_frame.integration, + integration_frame.relative_filename, found_frame.lineno, (found_frame.line or "?").strip(), + report_issue, ) + if strict: raise RuntimeError( "Blocking calls must be done in the executor or a separate thread;" f" {advise_msg or 'Use `await hass.async_add_executor_job()`'}; at" - f" {found_frame.filename[index:]}, line {found_frame.lineno}:" + f" {integration_frame.relative_filename}, line {found_frame.lineno}:" f" {(found_frame.line or '?').strip()}" ) @@ -167,7 +171,7 @@ def protect_loop(func: Callable[_P, _R], strict: bool = True) -> Callable[_P, _R return protected_loop_func -async def gather_with_concurrency( +async def gather_with_limited_concurrency( limit: int, *tasks: Any, return_exceptions: bool = False ) -> Any: """Wrap asyncio.gather to limit the number of concurrent tasks. diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index d9f2a4b96ff..8e7fc3dc155 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -172,7 +172,7 @@ COLORS = { "yellow": RGBColor(255, 255, 0), "yellowgreen": RGBColor(154, 205, 50), # And... - "homeassistant": RGBColor(3, 169, 244), + "homeassistant": RGBColor(24, 188, 242), } diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py deleted file mode 100644 index 45b105aea9f..00000000000 --- a/homeassistant/util/distance.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Distance util functions.""" -from __future__ import annotations - -from collections.abc import Callable - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - LENGTH, - LENGTH_CENTIMETERS, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, - LENGTH_YARD, - UNIT_NOT_RECOGNIZED_TEMPLATE, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import DistanceConverter - -VALID_UNITS = DistanceConverter.VALID_UNITS - -TO_METERS: dict[str, Callable[[float], float]] = { - LENGTH_METERS: lambda meters: meters, - LENGTH_MILES: lambda miles: miles * 1609.344, - LENGTH_YARD: lambda yards: yards * 0.9144, - LENGTH_FEET: lambda feet: feet * 0.3048, - LENGTH_INCHES: lambda inches: inches * 0.0254, - LENGTH_KILOMETERS: lambda kilometers: kilometers * 1000, - LENGTH_CENTIMETERS: lambda centimeters: centimeters * 0.01, - LENGTH_MILLIMETERS: lambda millimeters: millimeters * 0.001, -} - -METERS_TO: dict[str, Callable[[float], float]] = { - LENGTH_METERS: lambda meters: meters, - LENGTH_MILES: lambda meters: meters * 0.000621371, - LENGTH_YARD: lambda meters: meters * 1.09361, - LENGTH_FEET: lambda meters: meters * 3.28084, - LENGTH_INCHES: lambda meters: meters * 39.3701, - LENGTH_KILOMETERS: lambda meters: meters * 0.001, - LENGTH_CENTIMETERS: lambda meters: meters * 100, - LENGTH_MILLIMETERS: lambda meters: meters * 1000, -} - - -def convert(value: float, from_unit: str, to_unit: str) -> float: - """Convert one unit of measurement to another.""" - report( - ( - "uses distance utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.DistanceConverter instead" - ), - error_if_core=False, - ) - return DistanceConverter.convert(value, from_unit, to_unit) diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 1db30a6bdfa..1328e8ded60 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -16,21 +16,6 @@ from homeassistant.core import HomeAssistant, callback, is_callback _T = TypeVar("_T") -class HideSensitiveDataFilter(logging.Filter): - """Filter API password calls.""" - - def __init__(self, text: str) -> None: - """Initialize sensitive data filter.""" - super().__init__() - self.text = text - - def filter(self, record: logging.LogRecord) -> bool: - """Hide sensitive data in messages.""" - record.msg = record.msg.replace(self.text, "*******") - - return True - - class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index f92396f57df..46eaece25c4 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -7,10 +7,12 @@ import re import yarl # RFC6890 - IP addresses of loopback interfaces +IPV6_IPV4_LOOPBACK = ip_network("::ffff:127.0.0.0/104") + LOOPBACK_NETWORKS = ( ip_network("127.0.0.0/8"), ip_network("::1/128"), - ip_network("::ffff:127.0.0.0/104"), + IPV6_IPV4_LOOPBACK, ) # RFC6890 - Address allocation for Private Internets @@ -34,7 +36,7 @@ LINK_LOCAL_NETWORKS = ( def is_loopback(address: IPv4Address | IPv6Address) -> bool: """Check if an address is a loopback address.""" - return any(address in network for network in LOOPBACK_NETWORKS) + return address.is_loopback or address in IPV6_IPV4_LOOPBACK def is_private(address: IPv4Address | IPv6Address) -> bool: @@ -44,7 +46,7 @@ def is_private(address: IPv4Address | IPv6Address) -> bool: def is_link_local(address: IPv4Address | IPv6Address) -> bool: """Check if an address is link-local (local but not necessarily unique).""" - return any(address in network for network in LINK_LOCAL_NETWORKS) + return address.is_link_local def is_local(address: IPv4Address | IPv6Address) -> bool: @@ -54,7 +56,7 @@ def is_local(address: IPv4Address | IPv6Address) -> bool: def is_invalid(address: IPv4Address | IPv6Address) -> bool: """Check if an address is invalid.""" - return bool(address == ip_address("0.0.0.0")) + return address.is_unspecified def is_ip_address(address: str) -> bool: diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 7de75c1e24f..bc60953a1aa 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -67,9 +67,7 @@ def install_package( upgrade: bool = True, target: str | None = None, constraints: str | None = None, - find_links: str | None = None, timeout: int | None = None, - no_cache_dir: bool | None = False, ) -> bool: """Install a package on PyPi. Accepts pip compatible package strings. @@ -81,14 +79,10 @@ def install_package( args = [sys.executable, "-m", "pip", "install", "--quiet", package] if timeout: args += ["--timeout", str(timeout)] - if no_cache_dir: - args.append("--no-cache-dir") if upgrade: args.append("--upgrade") if constraints is not None: args += ["--constraint", constraints] - if find_links is not None: - args += ["--find-links", find_links, "--prefer-binary"] if target: assert not is_virtual_env() # This only works if not running in venv diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py deleted file mode 100644 index 9c5082e95ed..00000000000 --- a/homeassistant/util/pressure.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Pressure util functions.""" -from __future__ import annotations - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - PRESSURE, - PRESSURE_BAR, - PRESSURE_CBAR, - PRESSURE_HPA, - PRESSURE_INHG, - PRESSURE_KPA, - PRESSURE_MBAR, - PRESSURE_MMHG, - PRESSURE_PA, - PRESSURE_PSI, - UNIT_NOT_RECOGNIZED_TEMPLATE, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import PressureConverter - -# pylint: disable-next=protected-access -UNIT_CONVERSION: dict[str | None, float] = PressureConverter._UNIT_CONVERSION -VALID_UNITS = PressureConverter.VALID_UNITS - - -def convert(value: float, from_unit: str, to_unit: str) -> float: - """Convert one unit of measurement to another.""" - report( - ( - "uses pressure utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.PressureConverter instead" - ), - error_if_core=False, - ) - return PressureConverter.convert(value, from_unit, to_unit) diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py deleted file mode 100644 index 80a3609ab4d..00000000000 --- a/homeassistant/util/speed.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Distance util functions.""" -from __future__ import annotations - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - SPEED, - SPEED_FEET_PER_SECOND, - SPEED_INCHES_PER_DAY, - SPEED_INCHES_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - SPEED_KNOTS, - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, - UNIT_NOT_RECOGNIZED_TEMPLATE, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import ( # noqa: F401 - _FOOT_TO_M as FOOT_TO_M, - _HRS_TO_SECS as HRS_TO_SECS, - _IN_TO_M as IN_TO_M, - _KM_TO_M as KM_TO_M, - _MILE_TO_M as MILE_TO_M, - _NAUTICAL_MILE_TO_M as NAUTICAL_MILE_TO_M, - SpeedConverter, -) - -# pylint: disable-next=protected-access -UNIT_CONVERSION: dict[str | None, float] = SpeedConverter._UNIT_CONVERSION -VALID_UNITS = SpeedConverter.VALID_UNITS - - -def convert(value: float, from_unit: str, to_unit: str) -> float: - """Convert one unit of measurement to another.""" - report( - ( - "uses speed utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.SpeedConverter instead" - ), - error_if_core=False, - ) - return SpeedConverter.convert(value, from_unit, to_unit) diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py deleted file mode 100644 index 74d56e84d94..00000000000 --- a/homeassistant/util/temperature.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Temperature util functions.""" -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_KELVIN, - TEMPERATURE, - UNIT_NOT_RECOGNIZED_TEMPLATE, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import TemperatureConverter - -VALID_UNITS = TemperatureConverter.VALID_UNITS - - -def fahrenheit_to_celsius(fahrenheit: float, interval: bool = False) -> float: - """Convert a temperature in Fahrenheit to Celsius.""" - return convert(fahrenheit, TEMP_FAHRENHEIT, TEMP_CELSIUS, interval) - - -def kelvin_to_celsius(kelvin: float, interval: bool = False) -> float: - """Convert a temperature in Kelvin to Celsius.""" - return convert(kelvin, TEMP_KELVIN, TEMP_CELSIUS, interval) - - -def celsius_to_fahrenheit(celsius: float, interval: bool = False) -> float: - """Convert a temperature in Celsius to Fahrenheit.""" - return convert(celsius, TEMP_CELSIUS, TEMP_FAHRENHEIT, interval) - - -def celsius_to_kelvin(celsius: float, interval: bool = False) -> float: - """Convert a temperature in Celsius to Fahrenheit.""" - return convert(celsius, TEMP_CELSIUS, TEMP_KELVIN, interval) - - -def convert( - temperature: float, from_unit: str, to_unit: str, interval: bool = False -) -> float: - """Convert a temperature from one unit to another.""" - report( - ( - "uses temperature utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.TemperatureConverter instead" - ), - error_if_core=False, - ) - if interval: - return TemperatureConverter.convert_interval(temperature, from_unit, to_unit) - return TemperatureConverter.convert(temperature, from_unit, to_unit) diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py deleted file mode 100644 index 8aae8ff104e..00000000000 --- a/homeassistant/util/volume.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Volume conversion util functions.""" -from __future__ import annotations - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - UNIT_NOT_RECOGNIZED_TEMPLATE, - VOLUME, - VOLUME_CUBIC_FEET, - VOLUME_CUBIC_METERS, - VOLUME_FLUID_OUNCE, - VOLUME_GALLONS, - VOLUME_LITERS, - VOLUME_MILLILITERS, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import VolumeConverter - -VALID_UNITS = VolumeConverter.VALID_UNITS - - -def liter_to_gallon(liter: float) -> float: - """Convert a volume measurement in Liter to Gallon.""" - return convert(liter, VOLUME_LITERS, VOLUME_GALLONS) - - -def gallon_to_liter(gallon: float) -> float: - """Convert a volume measurement in Gallon to Liter.""" - return convert(gallon, VOLUME_GALLONS, VOLUME_LITERS) - - -def cubic_meter_to_cubic_feet(cubic_meter: float) -> float: - """Convert a volume measurement in cubic meter to cubic feet.""" - return convert(cubic_meter, VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET) - - -def cubic_feet_to_cubic_meter(cubic_feet: float) -> float: - """Convert a volume measurement in cubic feet to cubic meter.""" - return convert(cubic_feet, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS) - - -def convert(volume: float, from_unit: str, to_unit: str) -> float: - """Convert a volume from one unit to another.""" - report( - ( - "uses volume utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.VolumeConverter instead" - ), - error_if_core=False, - ) - return VolumeConverter.convert(volume, from_unit, to_unit) diff --git a/mypy.ini b/mypy.ini index c2ecac66946..92b96e75659 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,7 +7,6 @@ python_version = 3.11 plugins = pydantic.mypy show_error_codes = true follow_imports = silent -ignore_missing_imports = true local_partial_types = true strict_equality = true no_implicit_optional = true @@ -16,7 +15,7 @@ warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true enable_error_code = ignore-without-code, redundant-self, truthy-iterable -disable_error_code = annotation-unchecked +disable_error_code = annotation-unchecked, import-not-found, import-untyped extra_checks = false check_untyped_defs = true disallow_incomplete_defs = true @@ -792,6 +791,16 @@ warn_return_any = true warn_unreachable = true no_implicit_reexport = true +[mypy-homeassistant.components.discovergy.*] +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.dlna_dmr.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1322,87 +1331,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.homekit] -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.homekit.accessories] -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.homekit.aidmanager] -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.homekit.config_flow] -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.homekit.diagnostics] -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.homekit.logbook] -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.homekit.type_locks] -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.homekit.type_triggers] -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.homekit.util] +[mypy-homeassistant.components.homekit.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true @@ -1872,6 +1801,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.local_todo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lock.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3033,6 +2972,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tami4.*] +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.tautulli.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3193,6 +3142,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.transmission.*] +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.trend.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3424,6 +3383,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.withings.*] +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.wiz.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_super_call.py b/pylint/plugins/hass_enforce_super_call.py index db4b2d4a5d7..f2efb8bc8a2 100644 --- a/pylint/plugins/hass_enforce_super_call.py +++ b/pylint/plugins/hass_enforce_super_call.py @@ -11,7 +11,7 @@ METHODS = { } -class HassEnforceSuperCallChecker(BaseChecker): # type: ignore[misc] +class HassEnforceSuperCallChecker(BaseChecker): """Checker for super calls.""" name = "hass_enforce_super_call" diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 85b1b1370a1..f43dd9b6672 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2428,6 +2428,54 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ], ), ], + "todo": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="TodoListEntity", + matches=[ + TypeHintMatch( + function_name="todo_items", + return_type=["list[TodoItem]", None], + ), + TypeHintMatch( + function_name="async_create_todo_item", + arg_types={ + 1: "TodoItem", + }, + return_type="None", + ), + TypeHintMatch( + function_name="async_update_todo_item", + arg_types={ + 1: "TodoItem", + }, + return_type="None", + ), + TypeHintMatch( + function_name="async_delete_todo_items", + arg_types={ + 1: "list[str]", + }, + return_type="None", + ), + TypeHintMatch( + function_name="async_move_todo_item", + arg_types={ + 1: "str", + 2: "str | None", + }, + return_type="None", + ), + ], + ), + ], "tts": [ ClassTypeHintMatch( base_class="Provider", @@ -3014,7 +3062,7 @@ def _is_test_function(module_name: str, node: nodes.FunctionDef) -> bool: return module_name.startswith("tests.") and node.name.startswith("test_") -class HassTypeHintChecker(BaseChecker): # type: ignore[misc] +class HassTypeHintChecker(BaseChecker): """Checker for setup type hints.""" name = "hass_enforce_type_hints" diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 8b3aea61ff4..f28986b90e2 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -385,7 +385,7 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { } -class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] +class HassImportsFormatChecker(BaseChecker): """Checker for imports.""" name = "hass_imports" @@ -415,7 +415,7 @@ class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] } options = () - def __init__(self, linter: PyLinter | None = None) -> None: + def __init__(self, linter: PyLinter) -> None: """Initialize the HassImportsFormatChecker.""" super().__init__(linter) self.current_package: str | None = None diff --git a/pylint/plugins/hass_inheritance.py b/pylint/plugins/hass_inheritance.py index 716479202c7..7ae24ec6e6d 100644 --- a/pylint/plugins/hass_inheritance.py +++ b/pylint/plugins/hass_inheritance.py @@ -21,7 +21,7 @@ def _get_module_platform(module_name: str) -> str | None: return platform.lstrip(".") if platform else "__init__" -class HassInheritanceChecker(BaseChecker): # type: ignore[misc] +class HassInheritanceChecker(BaseChecker): """Checker for invalid inheritance.""" name = "hass_inheritance" diff --git a/pylint/plugins/hass_logger.py b/pylint/plugins/hass_logger.py index bfa05001304..e92fad2bdc0 100644 --- a/pylint/plugins/hass_logger.py +++ b/pylint/plugins/hass_logger.py @@ -9,7 +9,7 @@ LOGGER_NAMES = ("LOGGER", "_LOGGER") LOG_LEVEL_ALLOWED_LOWER_START = ("debug",) -class HassLoggerFormatChecker(BaseChecker): # type: ignore[misc] +class HassLoggerFormatChecker(BaseChecker): """Checker for logger invocations.""" name = "hass_logger" diff --git a/pyproject.toml b/pyproject.toml index d3754f7af52..7efa6915a46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.5" +version = "2023.11.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -23,7 +23,8 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.8.5", + "aiohttp==3.9.0b0;python_version>='3.12'", + "aiohttp==3.8.5;python_version<'3.12'", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", @@ -33,8 +34,8 @@ dependencies = [ "ciso8601==2.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.24.1", - "home-assistant-bluetooth==1.10.3", + "httpx==0.25.0", + "home-assistant-bluetooth==1.10.4", "ifaddr==0.2.0", "Jinja2==3.1.2", "lru-dict==1.2.0", @@ -43,14 +44,14 @@ dependencies = [ "cryptography==41.0.4", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.7", + "orjson==3.9.9", "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", "PyYAML==6.0.1", "requests==2.31.0", "typing-extensions>=4.8.0,<5.0", - "ulid-transform==0.8.1", + "ulid-transform==0.9.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.9.2", @@ -439,6 +440,8 @@ filterwarnings = [ "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", # https://github.com/michaeldavie/env_canada/blob/v0.6.0/env_canada/ec_cache.py "ignore:Inheritance class CacheClientSession from ClientSession is discouraged:DeprecationWarning:env_canada.ec_cache", + # https://github.com/allenporter/ical/pull/215 - v5.0.0 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", # https://github.com/bachya/regenmaschine/blob/2023.08.0/regenmaschine/client.py#L51 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", @@ -451,6 +454,13 @@ filterwarnings = [ # -- tracked upstream / open PRs # https://github.com/caronc/apprise/issues/659 - v1.4.5 "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", + # https://github.com/kiorky/croniter/issues/49 - v1.4.1 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", + # https://github.com/spulec/freezegun/issues/508 - v1.2.2 + # https://github.com/spulec/freezegun/pull/511 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:freezegun.api", + # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.37.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 "ignore:'imghdr' is deprecated and slated for removal in Python 3.13:DeprecationWarning:mediafile", # https://github.com/eclipse/paho.mqtt.python/issues/653 - v1.6.1 @@ -458,6 +468,8 @@ filterwarnings = [ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", # https://github.com/PythonCharmers/python-future/issues/488 - v0.18.3 "ignore:the imp module is deprecated in favour of importlib and slated for removal in Python 3.12:DeprecationWarning:future.standard_library", + # https://github.com/frenck/python-toonapi/pull/9 - v0.2.1 - 2021-09-23 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:toonapi.models", # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.2 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", # https://github.com/pytest-dev/pytest-cov/issues/557 - v4.1.0 @@ -465,16 +477,37 @@ filterwarnings = [ "ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated:DeprecationWarning:xdist.plugin", # -- fixed, waiting for release / update + # https://github.com/ludeeus/aiogithubapi/pull/208 - >=23.9.0 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiogithubapi.namespaces.events", + # https://github.com/bachya/aiopurpleair/pull/200 - >2023.08.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", + # https://github.com/scrapinghub/dateparser/pull/1179 - >1.1.8 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:dateparser.timezone_parser", + # https://github.com/zopefoundation/DateTime/pull/55 - >5.2 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:DateTime.pytz_support", # https://github.com/kurtmckee/feedparser/issues/330 - >6.0.10 "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", - # https://github.com/poljar/matrix-nio/pull/438 - >0.21.2 - "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", - # https://github.com/poljar/matrix-nio/pull/439 - >0.21.2 - "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", + # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", + # https://github.com/bachya/pytile/pull/280 - >2023.08.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytile.tile", + # https://github.com/rytilahti/python-miio/pull/1809 - >0.5.12 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", + # https://github.com/hunterjm/python-onvif-zeep-async/pull/51 - >3.1.12 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", + # Fixed upstream in python-telegram-bot - >=20.0 + "ignore:python-telegram-bot is using upstream urllib3:UserWarning:telegram.utils.request", + # https://github.com/ludeeus/pytraccar/pull/15 - >1.0.0 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytraccar.client", + # https://github.com/zopefoundation/RestrictedPython/pull/259 - >7.0a1.dev0 + "ignore:ast\\.(Str|Num) is deprecated and will be removed in Python 3.14:DeprecationWarning:RestrictedPython.transformer", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", + # https://github.com/Bluetooth-Devices/xiaomi-ble/pull/59 - >0.21.1 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:xiaomi_ble.parser", # -- not helpful # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 @@ -484,9 +517,22 @@ filterwarnings = [ # Locale changes might take some time to resolve upstream "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:homematicip.base.base_connection", "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:micloud.micloud", + # https://github.com/protocolbuffers/protobuf - v4.24.4 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:google.protobuf.internal.well_known_types", + "ignore:Type google._upb._message.(Message|Scalar)MapContainer uses PyType_Spec with a metaclass that has custom tp_new. .* Python 3.14:DeprecationWarning", + # https://github.com/googleapis/google-auth-library-python/blob/v2.23.3/google/auth/_helpers.py#L95 - v2.23.3 + "ignore:datetime.*utcnow\\(\\) is deprecated:DeprecationWarning:google.auth._helpers", + # https://github.com/googleapis/proto-plus-python/blob/v1.22.3/proto/datetime_helpers.py#L24 - v1.22.3 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated:DeprecationWarning:proto.datetime_helpers", + # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", + # https://github.com/Python-MyQ/Python-MyQ - v3.1.13 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pymyq.(api|account)", # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", + # New in aiohttp - v3.9.0 + "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 @@ -495,10 +541,13 @@ filterwarnings = [ "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", # https://pypi.org/project/emulated-roku/ - v0.2.1 - 2020-01-23 (archived) "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", # https://pypi.org/project/foobot_async/ - v1.0.0 - 2020-11-24 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/influxdb/ - v5.3.1 - 2020-11-11 (archived) + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 # https://github.com/vaidik/commentjson/issues/51 @@ -507,11 +556,17 @@ filterwarnings = [ "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", + # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", + # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", + # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", # https://pypi.org/project/vilfo-api-client/ - v0.4.1 - 2021-11-06 @@ -594,7 +649,12 @@ ignore = [ "D407", # Section name underlining "E501", # line too long "E731", # do not assign a lambda expression, use a def - "PLC1901", # Lots of false positives + + # Ignore ignored, as the rule is now back in preview/nursery, which cannot + # be ignored anymore without warnings. + # https://github.com/astral-sh/ruff/issues/7491 + # "PLC1901", # Lots of false positives + # False positives https://github.com/astral-sh/ruff/issues/5386 "PLC0208", # Use a sequence type instead of a `set` when iterating over values "PLR0911", # Too many return statements ({returns} > {max_returns}) @@ -623,6 +683,7 @@ voluptuous = "vol" fixture-parentheses = false [tool.ruff.flake8-tidy-imports.banned-api] +"async_timeout".msg = "use asyncio.timeout instead" "pytz".msg = "use zoneinfo instead" [tool.ruff.isort] diff --git a/requirements.txt b/requirements.txt index 60eb2359ba5..df08234d4db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.8.5 +aiohttp==3.9.0b0;python_version>='3.12' +aiohttp==3.8.5;python_version<'3.12' astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 @@ -9,22 +10,22 @@ awesomeversion==23.8.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 -httpx==0.24.1 -home-assistant-bluetooth==1.10.3 +httpx==0.25.0 +home-assistant-bluetooth==1.10.4 ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 PyJWT==2.8.0 cryptography==41.0.4 pyOpenSSL==23.2.0 -orjson==3.9.7 +orjson==3.9.9 packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 typing-extensions>=4.8.0,<5.0 -ulid-transform==0.8.1 +ulid-transform==0.9.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.9.2 diff --git a/requirements_all.txt b/requirements_all.txt index a4b7548384c..77633056811 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.8.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 @@ -43,10 +43,10 @@ Mastodon.py==1.5.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.1 +Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.3 +PlexAPI==4.15.4 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -113,7 +113,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.25.0 +PyViCare==2.28.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -122,14 +122,20 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.2 +RestrictedPython==6.2;python_version<'3.12' + +# homeassistant.components.python_script +RestrictedPython==7.0a1.dev0;python_version>='3.12' # homeassistant.components.remember_the_milk RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.21 +SQLAlchemy==2.0.22 + +# homeassistant.components.tami4 +Tami4EdgeAPI==2.1 # homeassistant.components.travisci TravisPy==0.3.5 @@ -141,10 +147,10 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==1.0.0 +accuweather==2.0.0 # homeassistant.components.adax -adax==0.2.0 +adax==0.3.0 # homeassistant.components.androidtv adb-shell[async]==0.4.4 @@ -153,7 +159,7 @@ adb-shell[async]==0.4.4 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.6.1 +adguardhome==0.6.2 # homeassistant.components.advantage_air advantage-air==0.4.4 @@ -186,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.3 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 @@ -210,7 +216,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.9 +aiocomelit==0.3.0 # homeassistant.components.dhcp aiodiscover==1.5.1 @@ -231,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.7 +aioesphomeapi==18.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -249,7 +255,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.5 +aiohomekit==3.0.9 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -363,13 +369,13 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==63 +aiounifi==64 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.3.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 @@ -380,6 +386,9 @@ aiowatttime==0.1.1 # homeassistant.components.webostv aiowebostv==0.3.3 +# homeassistant.components.withings +aiowithings==1.0.2 + # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -405,7 +414,7 @@ amberelectric==1.0.4 amcrest==1.9.8 # homeassistant.components.androidtv -androidtv[async]==0.0.72 +androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 @@ -426,7 +435,7 @@ apcaccess==0.0.13 apple_weatherkit==1.0.4 # homeassistant.components.apprise -apprise==1.5.0 +apprise==1.6.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -435,7 +444,7 @@ aprslib==0.7.0 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.1.3 +aranet4==2.2.2 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 @@ -512,25 +521,25 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.5 +bellows==0.36.8 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.1 +bimmer-connected==0.14.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 # homeassistant.components.blebox -blebox-uniapi==2.1.4 +blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.21.0 +blinkpy==0.22.2 # homeassistant.components.bitcoin blockchain==1.4.4 @@ -581,7 +590,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.1.1 +bthome-ble==3.2.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -642,7 +651,7 @@ crownstone-uart==2.1.0 datadog==0.15.0 # homeassistant.components.metoffice -datapoint==0.9.8 +datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth dbus-fast==2.12.0 @@ -692,7 +701,7 @@ dovado==0.4.1 dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr-parser==0.33 +dsmr-parser==1.3.0 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 @@ -722,7 +731,7 @@ ecoaliface==0.4.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==4.0.1 +elgato==5.0.0 # homeassistant.components.eliqonline eliqonline==1.2.2 @@ -752,7 +761,7 @@ enturclient==0.2.4 env-canada==0.6.0 # homeassistant.components.season -ephem==4.1.2 +ephem==4.1.5 # homeassistant.components.epson epson-projector==0.5.1 @@ -814,7 +823,7 @@ flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==0.4.1 +fnv-hash-fast==0.5.0 # homeassistant.components.foobot foobot_async==1.0.0 @@ -845,10 +854,10 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==4.1.4 +gcal-sync==5.0.0 # homeassistant.components.geniushub -geniushub-client==0.7.0 +geniushub-client==0.7.1 # homeassistant.components.geocaching geocachingapi==0.2.1 @@ -873,7 +882,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.1.0 +gios==3.2.0 # homeassistant.components.gitter gitterpy==0.1.7 @@ -888,6 +897,7 @@ goalzero==0.2.2 goodwe==0.2.31 # homeassistant.components.google_mail +# homeassistant.components.google_tasks google-api-python-client==2.71.0 # homeassistant.components.google_pubsub @@ -900,7 +910,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -909,7 +919,7 @@ googlemaps==2.5.1 goslide-api==0.5.1 # homeassistant.components.govee_ble -govee-ble==0.23.0 +govee-ble==0.24.0 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 @@ -939,7 +949,7 @@ gspread==5.5.0 gstreamer-player==1.1.2 # homeassistant.components.profiler -guppy3==3.1.3 +guppy3==3.1.4 # homeassistant.components.iaqualink h2==4.1.0 @@ -961,7 +971,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.71.0 +hass-nabucasa==0.74.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -997,10 +1007,10 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231005.0 +home-assistant-frontend==20231030.1 # homeassistant.components.conversation -home-assistant-intents==2023.10.2 +home-assistant-intents==2023.10.16 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1036,13 +1046,14 @@ ibeacon-ble==1.0.1 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==5.0.1 +# homeassistant.components.local_todo +ical==5.1.0 # homeassistant.components.ping icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==1.4.1 +idasen-ha==2.3 # homeassistant.components.network ifaddr==0.2.0 @@ -1183,7 +1194,7 @@ lxml==4.9.3 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-nio==0.21.2 +matrix-nio==0.22.1 # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -1267,7 +1278,7 @@ netdata==1.1.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.1.0 +nettigo-air-monitor==2.2.0 # homeassistant.components.neurio_energy neurio==0.3.1 @@ -1282,10 +1293,10 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.4.0 +nextdns==2.0.0 # homeassistant.components.nibe_heatpump -nibe==2.2.0 +nibe==2.4.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 @@ -1383,7 +1394,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.36 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1401,7 +1412,6 @@ ovoenergy==1.2.0 p1monitor==2.1.1 # homeassistant.components.mqtt -# homeassistant.components.shiftr paho-mqtt==1.6.1 # homeassistant.components.panasonic_bluray @@ -1444,10 +1454,10 @@ pizzapi==0.0.3 plexauth==0.0.6 # homeassistant.components.plex -plexwebsocket==0.0.13 +plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.0 +plugwise==0.33.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1478,7 +1488,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.5 +psutil==5.9.6 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 @@ -1493,22 +1503,25 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==1.0.0 +pvo==2.0.0 # homeassistant.components.canary py-canary==0.5.3 # homeassistant.components.cpuspeed -py-cpuinfo==8.0.0 +py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 +# homeassistant.components.improv_ble +py-improv-ble-client==1.0.3 + # homeassistant.components.melissa py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==0.1.5 +py-nextbusnext==1.0.0 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1540,9 +1553,6 @@ pyControl4==1.1.0 # homeassistant.components.duotecno pyDuotecno==2023.10.1 -# homeassistant.components.eight_sleep -pyEight==0.3.2 - # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1590,7 +1600,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.13.4 +pyatv==0.14.3 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 @@ -1656,13 +1666,13 @@ pydelijn==1.1.0 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==2.0.3 +pydiscovergy==2.0.5 # homeassistant.components.doods pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2023.8.0 +pydrawise==2023.10.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1674,7 +1684,7 @@ pyebox==1.1.4 pyecoforest==0.3.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.edimax pyedimax==0.2.1 @@ -1683,7 +1693,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.4 +pyenphase==1.13.1 # homeassistant.components.envisalink pyenvisalink==4.6 @@ -1701,7 +1711,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.2 +pyfibaro==0.7.6 # homeassistant.components.fido pyfido==2.1.2 @@ -1860,7 +1870,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.2 +pymodbus==3.5.4 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1922,7 +1932,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.9.0 +pyoverkiz==1.12.1 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1943,7 +1953,7 @@ pypjlink2==1.2.1 pyplaato==0.0.18 # homeassistant.components.point -pypoint==2.3.0 +pypoint==2.3.2 # homeassistant.components.profiler pyprof2calltree==1.4.5 @@ -1994,7 +2004,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.9.1 +pyschlage==2023.10.0 # homeassistant.components.sensibo pysensibo==1.0.35 @@ -2116,7 +2126,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==2.1.0 +python-homewizard-energy==2.1.2 # homeassistant.components.hp_ilo python-hpilo==4.3 @@ -2131,13 +2141,13 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.3 +python-kasa[speedups]==0.5.4 # homeassistant.components.lirc # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.7.0 +python-matter-server==4.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2155,7 +2165,7 @@ python-mystrom==2.2.0 python-opendata-transport==0.3.0 # homeassistant.components.opensky -python-opensky==0.2.0 +python-opensky==0.2.1 # homeassistant.components.otbr # homeassistant.components.thread @@ -2171,7 +2181,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.34.6 +python-roborock==0.35.0 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -2186,7 +2196,7 @@ python-tado==0.15.0 python-telegram-bot==13.1 # homeassistant.components.vlc -python-vlc==1.1.2 +python-vlc==3.0.18122 # homeassistant.components.egardia pythonegardia==1.0.52 @@ -2210,7 +2220,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.6 +pytrafikverket==0.3.7 # homeassistant.components.usb pyudev==0.23.2 @@ -2231,7 +2241,7 @@ pyvera==0.3.13 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.1 +pyvesync==2.1.10 # homeassistant.components.vizio pyvizio==0.1.61 @@ -2309,7 +2319,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.11 +reolink-aio==0.7.12 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2372,7 +2382,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2478,7 +2488,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 @@ -2502,7 +2512,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.6 +subarulink==0.7.8 # homeassistant.components.solarlog sunwatcher==0.2.1 @@ -2514,7 +2524,7 @@ surepy==0.8.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.1.0 +switchbot-api==1.2.1 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -2526,7 +2536,7 @@ systembridgeconnector==3.8.4 tailscale==0.2.0 # homeassistant.components.tank_utility -tank-utility==1.4.1 +tank-utility==1.5.0 # homeassistant.components.tapsaff tapsaff==0.2.1 @@ -2586,7 +2596,7 @@ todoist-api-python==2.1.2 tololib==0.1.0b4 # homeassistant.components.toon -toonapi==0.2.1 +toonapi==0.3.0 # homeassistant.components.totalconnect total-connect-client==2023.2 @@ -2607,7 +2617,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==1.0.0 +twentemilieu==2.0.0 # homeassistant.components.twilio twilio==6.32.0 @@ -2651,7 +2661,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2672,7 +2682,7 @@ volvooncall==0.10.3 vsure==2.6.6 # homeassistant.components.vasttrafik -vtjp==0.1.14 +vtjp==0.2.1 # homeassistant.components.vulcan vulcan-api==2.3.0 @@ -2685,7 +2695,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.4.12 +wallbox==0.4.14 # homeassistant.components.folder_watcher watchdog==2.3.1 @@ -2694,7 +2704,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.cisco_webex_teams -webexteamssdk==1.1.1 +webexteamssdk==1.1.1;python_version<'3.12' # homeassistant.components.assist_pipeline webrtc-noise-gain==1.2.3 @@ -2711,11 +2721,8 @@ wiffi==1.1.2 # homeassistant.components.wirelesstag wirelesstagpy==0.8.1 -# homeassistant.components.withings -withings-api==2.4.0 - # homeassistant.components.wled -wled==0.16.0 +wled==0.17.0 # homeassistant.components.wolflink wolf-smartset==0.1.11 @@ -2733,7 +2740,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2751,7 +2758,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.0 +yalexs-ble==2.3.1 # homeassistant.components.august yalexs==1.10.0 @@ -2781,13 +2788,13 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.115.2 +zeroconf==0.119.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.105 +zha-quirks==0.0.106 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2799,7 +2806,7 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.3 +zigpy-xbee==0.19.0 # homeassistant.components.zha zigpy-zigate==0.11.0 @@ -2808,13 +2815,13 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.6 # homeassistant.components.zha -zigpy==0.57.2 +zigpy==0.59.0 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.1 +zwave-js-server-python==0.53.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_docs.txt b/requirements_docs.txt deleted file mode 100644 index 17b38d6ebc3..00000000000 --- a/requirements_docs.txt +++ /dev/null @@ -1,3 +0,0 @@ -Sphinx==2.4.4 -sphinx-autodoc-typehints==1.10.3 -sphinx-autodoc-annotation==1.0.post1 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index 15404c159b9..1dc9139fde7 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.7 -coverage==7.3.1 +astroid==3.0.1 +coverage==7.3.2 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.5.1 -pre-commit==3.4.0 +mypy==1.6.1 +pre-commit==3.5.0 pydantic==1.10.12 -pylint==2.17.6 +pylint==3.0.2 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 @@ -28,7 +28,7 @@ pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==3.3.1 -pytest==7.3.1 +pytest==7.4.3 requests-mock==1.11.0 respx==0.20.2 syrupy==4.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 871084d8630..666c3ea4dc6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.8.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 @@ -39,10 +39,10 @@ HATasmota==0.7.3 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.1 +Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.3 +PlexAPI==4.15.4 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.25.0 +PyViCare==2.28.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -109,23 +109,29 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.2 +RestrictedPython==6.2;python_version<'3.12' + +# homeassistant.components.python_script +RestrictedPython==7.0a1.dev0;python_version>='3.12' # homeassistant.components.remember_the_milk RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.21 +SQLAlchemy==2.0.22 + +# homeassistant.components.tami4 +Tami4EdgeAPI==2.1 # homeassistant.components.onvif WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==1.0.0 +accuweather==2.0.0 # homeassistant.components.adax -adax==0.2.0 +adax==0.3.0 # homeassistant.components.androidtv adb-shell[async]==0.4.4 @@ -134,7 +140,7 @@ adb-shell[async]==0.4.4 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.6.1 +adguardhome==0.6.2 # homeassistant.components.advantage_air advantage-air==0.4.4 @@ -167,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.3 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 @@ -191,7 +197,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.9 +aiocomelit==0.3.0 # homeassistant.components.dhcp aiodiscover==1.5.1 @@ -212,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.7 +aioesphomeapi==18.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -227,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.5 +aiohomekit==3.0.9 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -338,13 +344,13 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==63 +aiounifi==64 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.3.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 @@ -355,6 +361,9 @@ aiowatttime==0.1.1 # homeassistant.components.webostv aiowebostv==0.3.3 +# homeassistant.components.withings +aiowithings==1.0.2 + # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -374,7 +383,7 @@ airtouch4pyapi==1.0.5 amberelectric==1.0.4 # homeassistant.components.androidtv -androidtv[async]==0.0.72 +androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 @@ -392,13 +401,13 @@ apcaccess==0.0.13 apple_weatherkit==1.0.4 # homeassistant.components.apprise -apprise==1.5.0 +apprise==1.6.0 # homeassistant.components.aprs aprslib==0.7.0 # homeassistant.components.aranet -aranet4==2.1.3 +aranet4==2.2.2 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 @@ -436,22 +445,22 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.5 +bellows==0.36.8 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.1 +bimmer-connected==0.14.2 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 # homeassistant.components.blebox -blebox-uniapi==2.1.4 +blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.21.0 +blinkpy==0.22.2 # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 @@ -488,7 +497,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.1.1 +bthome-ble==3.2.0 # homeassistant.components.buienradar buienradar==1.0.5 @@ -525,7 +534,7 @@ crownstone-uart==2.1.0 datadog==0.15.0 # homeassistant.components.metoffice -datapoint==0.9.8 +datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth dbus-fast==2.12.0 @@ -563,7 +572,7 @@ discovery30303==0.2.1 dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr-parser==0.33 +dsmr-parser==1.3.0 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 @@ -584,7 +593,7 @@ easyenergy==0.3.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==4.0.1 +elgato==5.0.0 # homeassistant.components.elkm1 elkm1-lib==2.2.6 @@ -608,7 +617,7 @@ enocean==0.50 env-canada==0.6.0 # homeassistant.components.season -ephem==4.1.2 +ephem==4.1.5 # homeassistant.components.epson epson-projector==0.5.1 @@ -645,7 +654,7 @@ flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==0.4.1 +fnv-hash-fast==0.5.0 # homeassistant.components.foobot foobot_async==1.0.0 @@ -670,7 +679,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==4.1.4 +gcal-sync==5.0.0 # homeassistant.components.geocaching geocachingapi==0.2.1 @@ -695,7 +704,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.1.0 +gios==3.2.0 # homeassistant.components.glances glances-api==0.4.3 @@ -707,6 +716,7 @@ goalzero==0.2.2 goodwe==0.2.31 # homeassistant.components.google_mail +# homeassistant.components.google_tasks google-api-python-client==2.71.0 # homeassistant.components.google_pubsub @@ -716,13 +726,13 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 # homeassistant.components.govee_ble -govee-ble==0.23.0 +govee-ble==0.24.0 # homeassistant.components.gree greeclimate==1.4.1 @@ -740,7 +750,7 @@ growattServer==1.3.0 gspread==5.5.0 # homeassistant.components.profiler -guppy3==3.1.3 +guppy3==3.1.4 # homeassistant.components.iaqualink h2==4.1.0 @@ -762,7 +772,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.71.0 +hass-nabucasa==0.74.0 # homeassistant.components.conversation hassil==1.2.5 @@ -786,10 +796,10 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231005.0 +home-assistant-frontend==20231030.1 # homeassistant.components.conversation -home-assistant-intents==2023.10.2 +home-assistant-intents==2023.10.16 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -816,13 +826,14 @@ iaqualink==0.5.0 ibeacon-ble==1.0.1 # homeassistant.components.local_calendar -ical==5.0.1 +# homeassistant.components.local_todo +ical==5.1.0 # homeassistant.components.ping icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==1.4.1 +idasen-ha==2.3 # homeassistant.components.network ifaddr==0.2.0 @@ -915,7 +926,7 @@ lxml==4.9.3 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-nio==0.21.2 +matrix-nio==0.22.1 # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -987,7 +998,7 @@ nessclient==1.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.1.0 +nettigo-air-monitor==2.2.0 # homeassistant.components.nexia nexia==2.0.7 @@ -999,10 +1010,10 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.4.0 +nextdns==2.0.0 # homeassistant.components.nibe_heatpump -nibe==2.2.0 +nibe==2.4.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 @@ -1061,7 +1072,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.36 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1073,7 +1084,6 @@ ovoenergy==1.2.0 p1monitor==2.1.1 # homeassistant.components.mqtt -# homeassistant.components.shiftr paho-mqtt==1.6.1 # homeassistant.components.panasonic_viera @@ -1104,10 +1114,10 @@ pilight==0.1.1 plexauth==0.0.6 # homeassistant.components.plex -plexwebsocket==0.0.13 +plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.0 +plugwise==0.33.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1138,22 +1148,25 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==1.0.0 +pvo==2.0.0 # homeassistant.components.canary py-canary==0.5.3 # homeassistant.components.cpuspeed -py-cpuinfo==8.0.0 +py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 +# homeassistant.components.improv_ble +py-improv-ble-client==1.0.3 + # homeassistant.components.melissa py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==0.1.5 +py-nextbusnext==1.0.0 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1173,9 +1186,6 @@ pyControl4==1.1.0 # homeassistant.components.duotecno pyDuotecno==2023.10.1 -# homeassistant.components.eight_sleep -pyEight==0.3.2 - # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1208,7 +1218,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.13.4 +pyatv==0.14.3 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 @@ -1247,10 +1257,10 @@ pydeconz==113 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==2.0.3 +pydiscovergy==2.0.5 # homeassistant.components.hydrawise -pydrawise==2023.8.0 +pydrawise==2023.10.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1259,13 +1269,13 @@ pydroid-ipcam==2.0.0 pyecoforest==0.3.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.efergy pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.4 +pyenphase==1.13.1 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1277,7 +1287,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.2 +pyfibaro==0.7.6 # homeassistant.components.fido pyfido==2.1.2 @@ -1397,7 +1407,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.2 +pymodbus==3.5.4 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1447,7 +1457,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.9.0 +pyoverkiz==1.12.1 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1465,7 +1475,7 @@ pypjlink2==1.2.1 pyplaato==0.0.18 # homeassistant.components.point -pypoint==2.3.0 +pypoint==2.3.2 # homeassistant.components.profiler pyprof2calltree==1.4.5 @@ -1501,7 +1511,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.9.1 +pyschlage==2023.10.0 # homeassistant.components.sensibo pysensibo==1.0.35 @@ -1578,7 +1588,7 @@ python-ecobee-api==0.2.14 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==2.1.0 +python-homewizard-energy==2.1.2 # homeassistant.components.izone python-izone==1.2.9 @@ -1587,10 +1597,10 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.3 +python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==3.7.0 +python-matter-server==4.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -1602,7 +1612,7 @@ python-myq==3.1.13 python-mystrom==2.2.0 # homeassistant.components.opensky -python-opensky==0.2.0 +python-opensky==0.2.1 # homeassistant.components.otbr # homeassistant.components.thread @@ -1615,7 +1625,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.34.6 +python-roborock==0.35.0 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -1645,7 +1655,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.6 +pytrafikverket==0.3.7 # homeassistant.components.usb pyudev==0.23.2 @@ -1660,7 +1670,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.13 # homeassistant.components.vesync -pyvesync==2.1.1 +pyvesync==2.1.10 # homeassistant.components.vizio pyvizio==0.1.61 @@ -1720,7 +1730,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.11 +reolink-aio==0.7.12 # homeassistant.components.rflink rflink==0.0.65 @@ -1759,7 +1769,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.backup securetar==2023.3.0 @@ -1841,7 +1851,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 @@ -1862,7 +1872,7 @@ stookwijzer==1.3.0 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.6 +subarulink==0.7.8 # homeassistant.components.solarlog sunwatcher==0.2.1 @@ -1871,7 +1881,7 @@ sunwatcher==0.2.1 surepy==0.8.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.1.0 +switchbot-api==1.2.1 # homeassistant.components.system_bridge systembridgeconnector==3.8.4 @@ -1910,7 +1920,7 @@ todoist-api-python==2.1.2 tololib==0.1.0b4 # homeassistant.components.toon -toonapi==0.2.1 +toonapi==0.3.0 # homeassistant.components.totalconnect total-connect-client==2023.2 @@ -1928,7 +1938,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==1.0.0 +twentemilieu==2.0.0 # homeassistant.components.twilio twilio==6.32.0 @@ -1969,7 +1979,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -1997,7 +2007,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.4.12 +wallbox==0.4.14 # homeassistant.components.folder_watcher watchdog==2.3.1 @@ -2014,11 +2024,8 @@ whois==0.9.27 # homeassistant.components.wiffi wiffi==1.1.2 -# homeassistant.components.withings -withings-api==2.4.0 - # homeassistant.components.wled -wled==0.16.0 +wled==0.17.0 # homeassistant.components.wolflink wolf-smartset==0.1.11 @@ -2036,7 +2043,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2051,7 +2058,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.0 +yalexs-ble==2.3.1 # homeassistant.components.august yalexs==1.10.0 @@ -2075,19 +2082,19 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.115.2 +zeroconf==0.119.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.105 +zha-quirks==0.0.106 # homeassistant.components.zha zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.3 +zigpy-xbee==0.19.0 # homeassistant.components.zha zigpy-zigate==0.11.0 @@ -2096,10 +2103,10 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.6 # homeassistant.components.zha -zigpy==0.57.2 +zigpy==0.59.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.1 +zwave-js-server-python==0.53.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index dadc3e0cab2..8891e6e210d 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -black==23.9.1 +black==23.10.0 codespell==2.2.2 -ruff==0.0.289 +ruff==0.1.1 yamllint==1.32.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e27b681f998..2668affee96 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -72,9 +72,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.58.0 -grpcio-status==1.58.0 -grpcio-reflection==1.58.0 +grpcio==1.59.0 +grpcio-status==1.59.0 +grpcio-reflection==1.59.0 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, @@ -104,9 +104,9 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.7.1 +anyio==4.0.0 h11==0.14.0 -httpcore==0.17.3 +httpcore==0.18.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index acdea23444d..d5acde61262 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -98,8 +98,8 @@ NO_IOT_CLASS = [ "proxy", "python_script", "raspberry_pi", + "recovery_mode", "repairs", - "safe_mode", "schedule", "script", "search", diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 779d76078d6..5513105fa17 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -35,7 +35,6 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { "show_error_codes": "true", "follow_imports": "silent", # Enable some checks globally. - "ignore_missing_imports": "true", "local_partial_types": "true", "strict_equality": "true", "no_implicit_optional": "true", @@ -50,7 +49,13 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { "truthy-iterable", ] ), - "disable_error_code": ", ".join(["annotation-unchecked"]), + "disable_error_code": ", ".join( + [ + "annotation-unchecked", + "import-not-found", + "import-untyped", + ] + ), # Impractical in real code # E.g. this breaks passthrough ParamSpec typing with Concatenate "extra_checks": "false", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 22c3e927703..4483aacd804 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -37,6 +37,7 @@ ALLOW_NAME_TRANSLATION = { "islamic_prayer_times", "local_calendar", "local_ip", + "local_todo", "nmap_tracker", "rpi_power", "waze_travel_time", @@ -532,6 +533,11 @@ def validate_translation_file( # noqa: C901 "translations", f"{reference['source']} contains invalid reference {reference['ref']}: Could not find {key}", ) + elif match := re.match(RE_REFERENCE, search[key]): + integration.add_error( + "translations", + f"Lokalise supports only one level of references: \"{reference['source']}\" should point to directly to \"{match.groups()[0]}\"", + ) def validate(integrations: dict[str, Integration], config: Config) -> None: diff --git a/script/lint_and_test.py b/script/lint_and_test.py index 27963758415..ee28d4765d6 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -173,8 +173,7 @@ async def main(): ) return - pyfile = re.compile(r".+\.py$") - pyfiles = [file for file in files if pyfile.match(file)] + pyfiles = [file for file in files if file.endswith(".py")] print("=============================") printc("bold", "CHANGED FILES:\n", "\n ".join(pyfiles)) @@ -224,7 +223,14 @@ async def main(): return code, _ = await async_exec( - "pytest", "-vv", "--force-sugar", "--", *test_files, display=True + "python3", + "-m", + "pytest", + "-vv", + "--force-sugar", + "--", + *test_files, + display=True, ) print("=============================") diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 42a8355db59..8dafd8fa802 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -103,9 +103,16 @@ def main(): if args.develop: print("Running tests") - print(f"$ pytest -vvv tests/components/{info.domain}") + print(f"$ python3 -m pytest -vvv tests/components/{info.domain}") subprocess.run( - ["pytest", "-vvv", f"tests/components/{info.domain}"], check=True + [ + "python3", + "-m", + "pytest", + "-vvv", + f"tests/components/{info.domain}", + ], + check=True, ) print() diff --git a/script/translations/clean.py b/script/translations/clean.py index 0dcf40941ef..0f2eb40300d 100644 --- a/script/translations/clean.py +++ b/script/translations/clean.py @@ -1,11 +1,10 @@ """Find translation keys that are in Lokalise but no longer defined in source.""" import argparse -import json from .const import CORE_PROJECT_ID, FRONTEND_DIR, FRONTEND_PROJECT_ID, INTEGRATIONS_DIR from .error import ExitApp from .lokalise import get_api -from .util import get_base_arg_parser +from .util import get_base_arg_parser, load_json_from_path def get_arguments() -> argparse.Namespace: @@ -46,9 +45,9 @@ def find_core(): translations = int_dir / "translations" / "en.json" - strings_json = json.loads(strings.read_text()) + strings_json = load_json_from_path(strings) if translations.is_file(): - translations_json = json.loads(translations.read_text()) + translations_json = load_json_from_path(translations) else: translations_json = {} @@ -69,8 +68,8 @@ def find_frontend(): missing_keys = [] find_extra( - json.loads(source.read_text()), - json.loads(translated.read_text()), + load_json_from_path(source), + load_json_from_path(translated), "", missing_keys, ) diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py index 86812318218..27764f0987f 100644 --- a/script/translations/deduplicate.py +++ b/script/translations/deduplicate.py @@ -9,7 +9,7 @@ from homeassistant.const import Platform from . import upload from .develop import flatten_translations -from .util import get_base_arg_parser +from .util import get_base_arg_parser, load_json_from_path def get_arguments() -> argparse.Namespace: @@ -101,7 +101,7 @@ def run(): for component in components: comp_strings_path = Path(STRINGS_PATH.format(component)) - strings[component] = json.loads(comp_strings_path.read_text(encoding="utf-8")) + strings[component] = load_json_from_path(comp_strings_path) for path, value in update_keys.items(): parts = path.split("::") diff --git a/script/translations/develop.py b/script/translations/develop.py index 3bfaa279e93..3e386afb641 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:((?:[a-z0-9-_]+|[:]{2})*)\%\]", value) + matches = re.findall(r"\[\%key:([a-z0-9_]+(?:::(?:[a-z0-9-_])+)+)\%\]", value) if not matches: return value diff --git a/script/translations/download.py b/script/translations/download.py index bcab3b511c3..d02b5c869aa 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -10,7 +10,7 @@ import subprocess from .const import CLI_2_DOCKER_IMAGE, CORE_PROJECT_ID, INTEGRATIONS_DIR from .error import ExitApp -from .util import get_lokalise_token +from .util import get_lokalise_token, load_json_from_path FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json") DOWNLOAD_DIR = pathlib.Path("build/translations-download").absolute() @@ -122,7 +122,7 @@ def write_integration_translations(): """Write integration translations.""" for lang_file in DOWNLOAD_DIR.glob("*.json"): lang = lang_file.stem - translations = json.loads(lang_file.read_text()) + translations = load_json_from_path(lang_file) save_language_translations(lang, translations) diff --git a/script/translations/error.py b/script/translations/error.py index bc8f21c23b5..210af95f325 100644 --- a/script/translations/error.py +++ b/script/translations/error.py @@ -1,4 +1,5 @@ """Errors for translations.""" +import json class ExitApp(Exception): @@ -8,3 +9,28 @@ class ExitApp(Exception): """Initialize the exit app exception.""" self.reason = reason self.exit_code = exit_code + + +class JSONDecodeErrorWithPath(json.JSONDecodeError): + """Subclass of JSONDecodeError with additional properties. + + Additional properties: + path: Path to the JSON document being parsed + """ + + def __init__(self, msg, doc, pos, path): + """Initialize.""" + lineno = doc.count("\n", 0, pos) + 1 + colno = pos - doc.rfind("\n", 0, pos) + errmsg = f"{msg}: file: {path} line {lineno} column {colno} (char {pos})" + ValueError.__init__(self, errmsg) + self.msg = msg + self.doc = doc + self.pos = pos + self.lineno = lineno + self.colno = colno + self.path = path + + def __reduce__(self): + """Reduce.""" + return self.__class__, (self.msg, self.doc, self.pos, self.path) diff --git a/script/translations/frontend.py b/script/translations/frontend.py index c955c240478..bb0e98e1c93 100644 --- a/script/translations/frontend.py +++ b/script/translations/frontend.py @@ -4,7 +4,7 @@ import json from .const import FRONTEND_DIR from .download import DOWNLOAD_DIR, run_download_docker -from .util import get_base_arg_parser +from .util import get_base_arg_parser, load_json_from_path FRONTEND_BACKEND_TRANSLATIONS = FRONTEND_DIR / "translations/backend" @@ -29,7 +29,7 @@ def run(): run_download_docker() for lang_file in DOWNLOAD_DIR.glob("*.json"): - translations = json.loads(lang_file.read_text()) + translations = load_json_from_path(lang_file) to_write_translations = {"component": {}} diff --git a/script/translations/migrate.py b/script/translations/migrate.py index f5bd60c30b4..c3057800973 100644 --- a/script/translations/migrate.py +++ b/script/translations/migrate.py @@ -6,6 +6,7 @@ import re from .const import CORE_PROJECT_ID, FRONTEND_PROJECT_ID, INTEGRATIONS_DIR from .lokalise import get_api +from .util import load_json_from_path FRONTEND_REPO = pathlib.Path("../frontend/") @@ -164,7 +165,7 @@ def find_and_rename_keys(): if not strings_file.is_file(): continue - strings = json.loads(strings_file.read_text()) + strings = load_json_from_path(strings_file) if "title" in strings.get("config", {}): from_key = f"component::{integration.name}::config::title" @@ -194,12 +195,12 @@ def interactive_update(): if not strings_file.is_file(): continue - strings = json.loads(strings_file.read_text()) + strings = load_json_from_path(strings_file) if "title" not in strings: continue - manifest = json.loads((integration / "manifest.json").read_text()) + manifest = load_json_from_path(integration / "manifest.json") print("Processing", manifest["name"]) print("Translation title", strings["title"]) @@ -247,9 +248,8 @@ def find_frontend_states(): Source key -> target key Add key to integrations strings.json """ - frontend_states = json.loads( - (FRONTEND_REPO / "src/translations/en.json").read_text() - )["state"] + path = FRONTEND_REPO / "src/translations/en.json" + frontend_states = load_json_from_path(path)["state"] # domain => state object to_write = {} @@ -307,7 +307,7 @@ def find_frontend_states(): for domain, state in to_write.items(): strings = INTEGRATIONS_DIR / domain / "strings.json" if strings.is_file(): - content = json.loads(strings.read_text()) + content = load_json_from_path(strings) else: content = {} @@ -326,7 +326,7 @@ def find_frontend_states(): def apply_data_references(to_migrate): """Apply references.""" for strings_file in INTEGRATIONS_DIR.glob("*/strings.json"): - strings = json.loads(strings_file.read_text()) + strings = load_json_from_path(strings_file) steps = strings.get("config", {}).get("step") if not steps: diff --git a/script/translations/upload.py b/script/translations/upload.py index 1a1819af863..eaf1c07ad91 100755 --- a/script/translations/upload.py +++ b/script/translations/upload.py @@ -8,7 +8,7 @@ import subprocess from .const import CLI_2_DOCKER_IMAGE, CORE_PROJECT_ID, INTEGRATIONS_DIR from .error import ExitApp -from .util import get_current_branch, get_lokalise_token +from .util import get_current_branch, get_lokalise_token, load_json_from_path FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json") LOCAL_FILE = pathlib.Path("build/translations-upload.json").absolute() @@ -52,7 +52,7 @@ def run_upload_docker(): def generate_upload_data(): """Generate the data for uploading.""" - translations = json.loads((INTEGRATIONS_DIR.parent / "strings.json").read_text()) + translations = load_json_from_path(INTEGRATIONS_DIR.parent / "strings.json") translations["component"] = {} for path in INTEGRATIONS_DIR.glob(f"*{os.sep}strings*.json"): @@ -66,7 +66,7 @@ def generate_upload_data(): platforms = parent.setdefault("platform", {}) parent = platforms.setdefault(platform, {}) - parent.update(json.loads(path.read_text())) + parent.update(load_json_from_path(path)) return translations diff --git a/script/translations/util.py b/script/translations/util.py index 9f41253fa02..aab98e049d9 100644 --- a/script/translations/util.py +++ b/script/translations/util.py @@ -1,10 +1,12 @@ """Translation utils.""" import argparse +import json import os import pathlib import subprocess +from typing import Any -from .error import ExitApp +from .error import ExitApp, JSONDecodeErrorWithPath def get_base_arg_parser() -> argparse.ArgumentParser: @@ -55,3 +57,11 @@ def get_current_branch(): .stdout.decode() .strip() ) + + +def load_json_from_path(path: pathlib.Path) -> Any: + """Load JSON from path.""" + try: + return json.loads(path.read_text()) + except json.JSONDecodeError as err: + raise JSONDecodeErrorWithPath(err.msg, err.doc, err.pos, path) from err diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 3cead230b1b..ef7beab488b 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -1,5 +1,6 @@ """Tests for the Home Assistant auth module.""" from datetime import timedelta +import time from typing import Any from unittest.mock import patch @@ -371,11 +372,15 @@ async def test_cannot_retrieve_expired_access_token(hass: HomeAssistant) -> None access_token = manager.async_create_access_token(refresh_token) assert await manager.async_validate_access_token(access_token) is refresh_token + # We patch time directly here because we want the access token to be created with + # an expired time, but we do not want to freeze time so that jwt will compare it + # to the patched time. If we freeze time for the test it will be frozen for jwt + # as well and the token will not be expired. with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - - auth_const.ACCESS_TOKEN_EXPIRATION - - timedelta(seconds=11), + "homeassistant.auth.time.time", + return_value=time.time() + - auth_const.ACCESS_TOKEN_EXPIRATION.total_seconds() + - 11, ): access_token = manager.async_create_access_token(refresh_token) diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py index 17039235f37..d208b6302bc 100644 --- a/tests/components/abode/test_init.py +++ b/tests/components/abode/test_init.py @@ -77,7 +77,10 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: ), ), patch( "homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth", - return_value={"type": data_entry_flow.FlowResultType.FORM}, + return_value={ + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "reauth_confirm", + }, ) as mock_async_step_reauth: await setup_platform(hass, ALARM_DOMAIN) diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py index 4e9d1698e8c..0356c6f3395 100644 --- a/tests/components/airnow/conftest.py +++ b/tests/components/airnow/conftest.py @@ -6,7 +6,6 @@ import pytest from homeassistant.components.airnow import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture @@ -60,8 +59,6 @@ def mock_api_get_fixture(data): async def setup_airnow_fixture(hass, config, mock_api_get): """Define a fixture to set up AirNow.""" with patch("pyairnow.WebServiceAPI._get", mock_api_get), patch( - "homeassistant.components.airnow.config_flow.WebServiceAPI._get", mock_api_get - ), patch("homeassistant.components.airnow.PLATFORMS", []): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + "homeassistant.components.airnow.PLATFORMS", [] + ): yield diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 8041cb55692..80c6de427ca 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -8,6 +8,7 @@ 'DateObserved': '2020-12-20', 'HourObserved': 15, 'Latitude': '**REDACTED**', + 'LocalTimeZone': 'PST', 'Longitude': '**REDACTED**', 'O3': 0.048, 'PM10': 12, diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index f62fc9aee22..f4a0fdeec1e 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -1,5 +1,5 @@ """Test the AirNow config flow.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import pytest @@ -142,12 +142,18 @@ async def test_options_flow(hass: HomeAssistant, setup_airnow) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_RADIUS: 25}, - ) + with patch( + "homeassistant.components.airnow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_RADIUS: 25}, + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == { CONF_RADIUS: 25, } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/airnow/test_diagnostics.py b/tests/components/airnow/test_diagnostics.py index ecf6acc1c80..50ff3ed2b32 100644 --- a/tests/components/airnow/test_diagnostics.py +++ b/tests/components/airnow/test_diagnostics.py @@ -15,6 +15,7 @@ async def test_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) assert ( await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == snapshot diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 44bd0e45e2a..1d1d060e80a 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -173,6 +173,10 @@ 'aidoo1', ]), 'available': True, + 'groups': list([ + 'group1', + 'grp2', + ]), 'humidity': 27, 'id': 'installation1', 'mode': 2, @@ -185,6 +189,7 @@ ]), 'name': 'House', 'num-devices': 3, + 'num-groups': 2, 'power': True, 'systems': list([ 'system1', diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py index acf1d082c29..4106b1af1e9 100644 --- a/tests/components/airzone_cloud/test_climate.py +++ b/tests/components/airzone_cloud/test_climate.py @@ -37,40 +37,96 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Aidoos + state = hass.states.get("climate.bron") + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + + # Groups + state = hass.states.get("climate.group") + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 27 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 24.0 + + # Installations + state = hass.states.get("climate.house") + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 27 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 23.3 + # Zones state = hass.states.get("climate.dormitorio") assert state.state == HVACMode.OFF - assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 24 - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25.0 - assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF - assert state.attributes.get(ATTR_HVAC_MODES) == [ + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 24 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.COOL, HVACMode.HEAT, HVACMode.FAN_ONLY, HVACMode.DRY, HVACMode.OFF, ] - assert state.attributes.get(ATTR_MAX_TEMP) == 30 - assert state.attributes.get(ATTR_MIN_TEMP) == 15 - assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP - assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 24.0 state = hass.states.get("climate.salon") assert state.state == HVACMode.COOL - assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 30 - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.0 - assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING - assert state.attributes.get(ATTR_HVAC_MODES) == [ + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 30 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.COOL, HVACMode.HEAT, HVACMode.FAN_ONLY, HVACMode.DRY, HVACMode.OFF, ] - assert state.attributes.get(ATTR_MAX_TEMP) == 30 - assert state.attributes.get(ATTR_MIN_TEMP) == 15 - assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP - assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 24.0 async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: @@ -78,6 +134,89 @@ async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Aidoos + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.bron", + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.state == HVACMode.HEAT + + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.group", + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "climate.group", + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.OFF + + # Installations + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.house", + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.state == HVACMode.COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "climate.house", + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.state == HVACMode.OFF + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -117,6 +256,111 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Aidoos + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.bron", + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.state == HVACMode.HEAT_COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.bron", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.state == HVACMode.OFF + + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_HVAC_MODE: HVACMode.DRY, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.DRY + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.OFF + + # Installations + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.house", + ATTR_HVAC_MODE: HVACMode.DRY, + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.state == HVACMode.DRY + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.house", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.state == HVACMode.OFF + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -181,6 +425,42 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.attributes[ATTR_TEMPERATURE] == 20.5 + + # Installations + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.house", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.attributes[ATTR_TEMPERATURE] == 20.5 + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -197,7 +477,7 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.salon") - assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + assert state.attributes[ATTR_TEMPERATURE] == 20.5 async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: @@ -205,6 +485,60 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Aidoos + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.bron", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.attributes[ATTR_TEMPERATURE] == 24.0 + + # Installations + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.house", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.attributes[ATTR_TEMPERATURE] == 23.3 + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -221,4 +555,4 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.salon") - assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + assert state.attributes[ATTR_TEMPERATURE] == 24.0 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 412f0df1337..76349d06481 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -101,6 +101,7 @@ GET_INSTALLATION_MOCK = { API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone1", API_NAME: "Salon", API_TYPE: API_AZ_ZONE, @@ -111,6 +112,7 @@ GET_INSTALLATION_MOCK = { API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone2", API_NAME: "Dormitorio", API_TYPE: API_AZ_ZONE, diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 8ba196de545..08ccef37336 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -426,6 +426,7 @@ async def test_get_action_capabilities_arm_code_legacy( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: @@ -433,10 +434,17 @@ async def test_action( platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["no_arm_code"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -451,7 +459,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_away", }, @@ -463,7 +471,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_home", }, @@ -475,7 +483,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_night", }, @@ -487,7 +495,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_vacation", }, @@ -496,7 +504,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_disarm"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "disarm", "code": "1234", @@ -509,7 +517,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "trigger", }, @@ -549,6 +557,7 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: @@ -556,10 +565,17 @@ async def test_action_legacy( platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["no_arm_code"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -574,7 +590,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.entity_id, "type": "arm_away", }, diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index f1719b83d38..6e85c94379f 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -185,10 +185,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for all conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -201,7 +212,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_triggered", } @@ -223,7 +234,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_disarmed", } @@ -245,7 +256,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_home", } @@ -267,7 +278,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_away", } @@ -289,7 +300,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_night", } @@ -311,7 +322,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_vacation", } @@ -333,7 +344,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_custom_bypass", } @@ -438,10 +449,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for all conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -454,7 +476,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_triggered", } diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 57b9f8125c2..70d700bb290 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -246,10 +246,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ): """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ALARM_PENDING) @@ -262,7 +273,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "triggered", }, @@ -284,7 +295,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "disarmed", }, @@ -306,7 +317,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_home", }, @@ -328,7 +339,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_away", }, @@ -350,7 +361,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_night", }, @@ -372,7 +383,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_vacation", }, @@ -450,10 +461,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) @@ -466,7 +488,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "triggered", "for": {"seconds": 5}, @@ -507,10 +529,21 @@ async def test_if_fires_on_state_change_with_for( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) @@ -523,7 +556,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "triggered", }, diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index bbdf3efeb5f..e24ec4c950b 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -272,6 +272,46 @@ async def test_dimmable_light(hass: HomeAssistant) -> None: assert call.data["brightness_pct"] == 50 +async def test_dimmable_light_with_none_brightness(hass: HomeAssistant) -> None: + """Test dimmable light discovery.""" + device = ( + "light.test_2", + "on", + { + "brightness": None, + "friendly_name": "Test light 2", + "supported_color_modes": ["brightness"], + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "light#test_2" + assert appliance["displayCategories"][0] == "LIGHT" + assert appliance["friendlyName"] == "Test light 2" + + assert_endpoint_capabilities( + appliance, + "Alexa.BrightnessController", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "light#test_2") + properties.assert_equal("Alexa.PowerController", "powerState", "ON") + properties.assert_equal("Alexa.BrightnessController", "brightness", 0) + + call, _ = await assert_request_calls_service( + "Alexa.BrightnessController", + "SetBrightness", + "light#test_2", + "light.turn_on", + hass, + payload={"brightness": "50"}, + ) + assert call.data["brightness_pct"] == 50 + + @pytest.mark.parametrize( "supported_color_modes", [["color_temp", "hs"], ["color_temp", "rgb"], ["color_temp", "xy"]], @@ -310,6 +350,55 @@ async def test_color_light( # tests +async def test_color_light_turned_off(hass: HomeAssistant) -> None: + """Test color light discovery with turned off light.""" + device = ( + "light.test_off", + "off", + { + "friendly_name": "Test light off", + "supported_color_modes": ["color_temp", "hs"], + "hs_color": None, + "color_temp": None, + "brightness": None, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "light#test_off" + assert appliance["displayCategories"][0] == "LIGHT" + assert appliance["friendlyName"] == "Test light off" + + assert_endpoint_capabilities( + appliance, + "Alexa.BrightnessController", + "Alexa.PowerController", + "Alexa.ColorController", + "Alexa.ColorTemperatureController", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "light#test_off") + properties.assert_equal("Alexa.PowerController", "powerState", "OFF") + properties.assert_equal("Alexa.BrightnessController", "brightness", 0) + properties.assert_equal( + "Alexa.ColorController", + "color", + {"hue": 0.0, "saturation": 0.0, "brightness": 0.0}, + ) + + call, _ = await assert_request_calls_service( + "Alexa.BrightnessController", + "SetBrightness", + "light#test_off", + "light.turn_on", + hass, + payload={"brightness": "50"}, + ) + assert call.data["brightness_pct"] == 50 + + @pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_script(hass: HomeAssistant) -> None: """Test script discovery.""" diff --git a/tests/components/apache_kafka/conftest.py b/tests/components/apache_kafka/conftest.py new file mode 100644 index 00000000000..9391ccdd380 --- /dev/null +++ b/tests/components/apache_kafka/conftest.py @@ -0,0 +1,5 @@ +"""Skip test collection.""" +import sys + +if sys.version_info >= (3, 12): + collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/apple_tv/test_remote.py b/tests/components/apple_tv/test_remote.py new file mode 100644 index 00000000000..db2a4964f6c --- /dev/null +++ b/tests/components/apple_tv/test_remote.py @@ -0,0 +1,28 @@ +"""Test apple_tv remote.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.apple_tv.remote import AppleTVRemote +from homeassistant.components.remote import ATTR_DELAY_SECS, ATTR_NUM_REPEATS + + +@pytest.mark.parametrize( + ("command", "method"), + [ + ("up", "remote_control.up"), + ("wakeup", "power.turn_on"), + ("volume_up", "audio.volume_up"), + ("home_hold", "remote_control.home"), + ], + ids=["up", "wakeup", "volume_up", "home_hold"], +) +async def test_send_command(command: str, method: str) -> None: + """Test "send_command" method.""" + remote = AppleTVRemote("test", "test", None) + remote.atv = AsyncMock() + await remote.async_send_command( + [command], **{ATTR_NUM_REPEATS: 1, ATTR_DELAY_SECS: 0} + ) + assert len(remote.atv.method_calls) == 1 + assert str(remote.atv.method_calls[0]) == f"call.{method}()" diff --git a/tests/components/aranet/__init__.py b/tests/components/aranet/__init__.py index c85748abea4..b559743067d 100644 --- a/tests/components/aranet/__init__.py +++ b/tests/components/aranet/__init__.py @@ -57,3 +57,11 @@ VALID_DATA_SERVICE_INFO = fake_service_info( 1794: b'\x21\x00\x02\x01\x00\x00\x00\x01\x8a\x02\xa5\x01\xb1&"Y\x01,\x01\xe8\x00\x88' }, ) + +VALID_ARANET2_DATA_SERVICE_INFO = fake_service_info( + "Aranet2 12345", + "0000fce0-0000-1000-8000-00805f9b34fb", + { + 1794: b"\x01!\x04\x04\x01\x00\x00\x00\x00\x00\xf0\x01\x00\x00\x0c\x02\x00O\x00<\x00\x01\x00\x80" + }, +) diff --git a/tests/components/aranet/test_sensor.py b/tests/components/aranet/test_sensor.py index 7d531bf6111..0b2b4771069 100644 --- a/tests/components/aranet/test_sensor.py +++ b/tests/components/aranet/test_sensor.py @@ -4,16 +4,70 @@ from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -from . import DISABLED_INTEGRATIONS_SERVICE_INFO, VALID_DATA_SERVICE_INFO +from . import ( + DISABLED_INTEGRATIONS_SERVICE_INFO, + VALID_ARANET2_DATA_SERVICE_INFO, + VALID_DATA_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors( +async def test_sensors_aranet2( hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: - """Test setting up creates the sensors.""" + """Test setting up creates the sensors for Aranet2 device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 0 + inject_bluetooth_service_info(hass, VALID_ARANET2_DATA_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 4 + + batt_sensor = hass.states.get("sensor.aranet2_12345_battery") + batt_sensor_attrs = batt_sensor.attributes + assert batt_sensor.state == "79" + assert batt_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet2 12345 Battery" + assert batt_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert batt_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humid_sensor = hass.states.get("sensor.aranet2_12345_humidity") + humid_sensor_attrs = humid_sensor.attributes + assert humid_sensor.state == "52.4" + assert humid_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet2 12345 Humidity" + assert humid_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humid_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + temp_sensor = hass.states.get("sensor.aranet2_12345_temperature") + temp_sensor_attrs = temp_sensor.attributes + assert temp_sensor.state == "24.8" + assert temp_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet2 12345 Temperature" + assert temp_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + interval_sensor = hass.states.get("sensor.aranet2_12345_update_interval") + interval_sensor_attrs = interval_sensor.attributes + assert interval_sensor.state == "60" + assert interval_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet2 12345 Update Interval" + assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" + assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sensors_aranet4( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test setting up creates the sensors for Aranet4 device.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", @@ -90,22 +144,7 @@ async def test_smart_home_integration_disabled( assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, DISABLED_INTEGRATIONS_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 6 - - batt_sensor = hass.states.get("sensor.aranet4_12345_battery") - assert batt_sensor.state == "unavailable" - - co2_sensor = hass.states.get("sensor.aranet4_12345_carbon_dioxide") - assert co2_sensor.state == "unavailable" - - humid_sensor = hass.states.get("sensor.aranet4_12345_humidity") - assert humid_sensor.state == "unavailable" - - temp_sensor = hass.states.get("sensor.aranet4_12345_temperature") - assert temp_sensor.state == "unavailable" - - press_sensor = hass.states.get("sensor.aranet4_12345_pressure") - assert press_sensor.state == "unavailable" + assert len(hass.states.async_all("sensor")) == 0 assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py index 7caba687ff2..d073e9c75da 100644 --- a/tests/components/arcam_fmj/test_device_trigger.py +++ b/tests/components/arcam_fmj/test_device_trigger.py @@ -82,7 +82,7 @@ async def test_if_fires_on_turn_on_request( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.id, "type": "turn_on", }, @@ -128,7 +128,7 @@ async def test_if_fires_on_turn_on_request_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.entity_id, "type": "turn_on", }, diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 1a3144ee069..97f80a33d1d 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -181,6 +181,49 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): url_path = "wake_word.test" _attr_name = "test" + alternate_detections = False + detected_wake_word_index = 0 + + async def get_supported_wake_words(self) -> list[wake_word.WakeWord]: + """Return a list of supported wake words.""" + return [ + wake_word.WakeWord(id="test_ww", name="Test Wake Word"), + wake_word.WakeWord(id="test_ww_2", name="Test Wake Word 2"), + ] + + async def _async_process_audio_stream( + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None + ) -> wake_word.DetectionResult | None: + """Try to detect wake word(s) in an audio stream with timestamps.""" + wake_words = await self.get_supported_wake_words() + + if self.alternate_detections: + detected_id = wake_words[self.detected_wake_word_index].id + self.detected_wake_word_index = (self.detected_wake_word_index + 1) % len( + wake_words + ) + else: + detected_id = wake_words[0].id + + async for chunk, timestamp in stream: + if chunk.startswith(b"wake word"): + return wake_word.DetectionResult( + wake_word_id=detected_id, + timestamp=timestamp, + queued_audio=[(b"queued audio", 0)], + ) + + # Not detected + return None + + +class MockWakeWordEntity2(wake_word.WakeWordDetectionEntity): + """Second mock wake word entity to test cooldown.""" + + fail_process_audio = False + url_path = "wake_word.test2" + _attr_name = "test2" + async def get_supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" return [wake_word.WakeWord(id="test_ww", name="Test Wake Word")] @@ -189,12 +232,12 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" - if wake_word_id is None: - wake_word_id = (await self.get_supported_wake_words())[0].id + wake_words = await self.get_supported_wake_words() + async for chunk, timestamp in stream: if chunk.startswith(b"wake word"): return wake_word.DetectionResult( - wake_word_id=wake_word_id, + wake_word_id=wake_words[0].id, timestamp=timestamp, queued_audio=[(b"queued audio", 0)], ) @@ -209,6 +252,12 @@ async def mock_wake_word_provider_entity(hass) -> MockWakeWordEntity: return MockWakeWordEntity() +@pytest.fixture +async def mock_wake_word_provider_entity2(hass) -> MockWakeWordEntity2: + """Mock wake word provider.""" + return MockWakeWordEntity2() + + class MockFlow(ConfigFlow): """Test flow.""" @@ -229,6 +278,7 @@ async def init_supporting_components( mock_stt_provider_entity: MockSttProviderEntity, mock_tts_provider: MockTTSProvider, mock_wake_word_provider_entity: MockWakeWordEntity, + mock_wake_word_provider_entity2: MockWakeWordEntity2, config_flow_fixture, ): """Initialize relevant components with empty configs.""" @@ -265,7 +315,9 @@ async def init_supporting_components( async_add_entities: AddEntitiesCallback, ) -> None: """Set up test wake word platform via config entry.""" - async_add_entities([mock_wake_word_provider_entity]) + async_add_entities( + [mock_wake_word_provider_entity, mock_wake_word_provider_entity2] + ) mock_integration( hass, diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index b8c668f3fd0..9eb7e1e5a05 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -717,3 +717,173 @@ 'message': '', }) # --- +# name: test_wake_word_cooldown_different_entities + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_different_entities.1 + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_different_entities.2 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_different_entities.3 + dict({ + 'entity_id': 'wake_word.test2', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_different_entities.4 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'wake_word_id': 'test_ww', + }), + }) +# --- +# name: test_wake_word_cooldown_different_entities.5 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'wake_word_id': 'test_ww', + }), + }) +# --- +# name: test_wake_word_cooldown_different_ids + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_different_ids.1 + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_different_ids.2 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_different_ids.3 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_different_ids.4 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'wake_word_id': 'test_ww', + }), + }) +# --- +# name: test_wake_word_cooldown_different_ids.5 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'wake_word_id': 'test_ww_2', + }), + }) +# --- +# name: test_wake_word_cooldown_same_id + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_same_id.1 + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_same_id.2 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_same_id.3 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 28b31e5b19c..9a4e78a29af 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -9,7 +9,7 @@ from homeassistant.components.assist_pipeline.pipeline import Pipeline, Pipeline from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import MockWakeWordEntity +from .conftest import MockWakeWordEntity, MockWakeWordEntity2 from tests.typing import WebSocketGenerator @@ -1809,14 +1809,14 @@ async def test_audio_pipeline_with_enhancements( assert msg["result"] == {"events": events} -async def test_wake_word_cooldown( +async def test_wake_word_cooldown_same_id( hass: HomeAssistant, init_components, mock_wake_word_provider_entity: MockWakeWordEntity, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, ) -> None: - """Test that duplicate wake word detections are blocked during the cooldown period.""" + """Test that duplicate wake word detections with the same id are blocked during the cooldown period.""" client_1 = await hass_ws_client(hass) client_2 = await hass_ws_client(hass) @@ -1888,3 +1888,219 @@ async def test_wake_word_cooldown( # One should be a wake up, one should be an error assert {event_type_1, event_type_2} == {"wake_word-end", "error"} + + +async def test_wake_word_cooldown_different_ids( + hass: HomeAssistant, + init_components, + mock_wake_word_provider_entity: MockWakeWordEntity, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test that duplicate wake word detections are allowed with different ids.""" + with patch.object(mock_wake_word_provider_entity, "alternate_detections", True): + client_1 = await hass_ws_client(hass) + client_2 = await hass_ws_client(hass) + + await client_1.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + await client_2.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + # result + msg = await client_1.receive_json() + assert msg["success"], msg + + msg = await client_2.receive_json() + assert msg["success"], msg + + # run start + msg = await client_1.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_1 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_2 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + # wake_word + msg = await client_1.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + # Wake both up at the same time, but they will have different wake word ids + await client_1.send_bytes(bytes([handler_id_1]) + b"wake word") + await client_2.send_bytes(bytes([handler_id_2]) + b"wake word") + + # Get response events + msg = await client_1.receive_json() + event_type_1 = msg["event"]["type"] + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + event_type_2 = msg["event"]["type"] + assert msg["event"]["data"] == snapshot + + # Both should wake up now + assert {event_type_1, event_type_2} == {"wake_word-end"} + + +async def test_wake_word_cooldown_different_entities( + hass: HomeAssistant, + init_components, + mock_wake_word_provider_entity: MockWakeWordEntity, + mock_wake_word_provider_entity2: MockWakeWordEntity2, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test that duplicate wake word detections are allowed with different entities.""" + client_pipeline = await hass_ws_client(hass) + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": "en-US", + "language": "en", + "name": "pipeline_with_wake_word_1", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": mock_wake_word_provider_entity.entity_id, + "wake_word_id": "test_ww", + } + ) + msg = await client_pipeline.receive_json() + assert msg["success"] + pipeline_id_1 = msg["result"]["id"] + + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": "en-US", + "language": "en", + "name": "pipeline_with_wake_word_2", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": mock_wake_word_provider_entity2.entity_id, + "wake_word_id": "test_ww", + } + ) + msg = await client_pipeline.receive_json() + assert msg["success"] + pipeline_id_2 = msg["result"]["id"] + + # Wake word clients + client_1 = await hass_ws_client(hass) + client_2 = await hass_ws_client(hass) + + await client_1.send_json_auto_id( + { + "type": "assist_pipeline/run", + "pipeline": pipeline_id_1, + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + # Use different wake word entity + await client_2.send_json_auto_id( + { + "type": "assist_pipeline/run", + "pipeline": pipeline_id_2, + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + # result + msg = await client_1.receive_json() + assert msg["success"], msg + + msg = await client_2.receive_json() + assert msg["success"], msg + + # run start + msg = await client_1.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_1 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_2 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + # wake_word + msg = await client_1.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + # Wake both up at the same time. + # They will have the same wake word id, but different entities. + await client_1.send_bytes(bytes([handler_id_1]) + b"wake word") + await client_2.send_bytes(bytes([handler_id_2]) + b"wake word") + + # Get response events + msg = await client_1.receive_json() + assert msg["event"]["type"] == "wake_word-end", msg + ww_id_1 = msg["event"]["data"]["wake_word_output"]["wake_word_id"] + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "wake_word-end", msg + ww_id_2 = msg["event"]["data"]["wake_word_output"]["wake_word_id"] + assert msg["event"]["data"] == snapshot + + # Wake words should be the same + assert ww_id_1 == ww_id_2 diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index d0da8ce6d53..ae7d46dcb22 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -331,15 +331,12 @@ async def test_restored_state( # Home assistant is not running yet hass.state = CoreState.not_running - last_reset = "2023-09-22T00:00:00.000000+00:00" mock_restore_cache_with_extra_data( hass, [ ( fake_state, - { - "last_reset": last_reset, - }, + {"native_value": "Tag Unlock", "native_unit_of_measurement": None}, ) ], ) diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index 3eb1972011c..1430eca3a26 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -23,7 +23,10 @@ async def test_auth_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure.""" with patch( "homeassistant.components.aussie_broadband.config_flow.ConfigFlow.async_step_reauth", - return_value={"type": data_entry_flow.FlowResultType.FORM}, + return_value={ + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "reauth_confirm", + }, ) as mock_async_step_reauth: await setup_platform(hass, side_effect=AuthenticationException()) mock_async_step_reauth.assert_called_once() diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index ad35a2cfbdd..2976886881d 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -7,13 +7,15 @@ from unittest.mock import patch import pytest +from homeassistant import config_entries from homeassistant.components import automation from homeassistant.components.blueprint import models from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, yaml -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service BUILTIN_BLUEPRINT_FOLDER = pathlib.Path(automation.__file__).parent / "blueprints" @@ -40,8 +42,18 @@ def patch_blueprint(blueprint_path: str, data_path): yield -async def test_notify_leaving_zone(hass: HomeAssistant) -> None: +async def test_notify_leaving_zone( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test notifying leaving a zone blueprint.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) def set_person_state(state, extra={}): hass.states.async_set( @@ -68,7 +80,7 @@ async def test_notify_leaving_zone(hass: HomeAssistant) -> None: "input": { "person_entity": "person.test_person", "zone_entity": "zone.school", - "notify_device": "abcdefgh", + "notify_device": device.id, }, } } @@ -89,7 +101,7 @@ async def test_notify_leaving_zone(hass: HomeAssistant) -> None: "alias": "Notify that a person has left the zone", "domain": "mobile_app", "type": "notify", - "device_id": "abcdefgh", + "device_id": device.id, } message_tpl.hass = hass assert message_tpl.async_render(variables) == "Paulus has left School" diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 0d983864e44..6d83b00517d 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch import pytest +from homeassistant import config_entries import homeassistant.components.automation as automation from homeassistant.components.automation import ( ATTR_SOURCE, @@ -36,6 +37,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.script import ( SCRIPT_MODE_CHOICES, SCRIPT_MODE_PARALLEL, @@ -49,6 +51,7 @@ from homeassistant.util import yaml import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, MockUser, assert_setup_component, async_capture_events, @@ -1589,8 +1592,31 @@ async def test_extraction_functions_unavailable_automation(hass: HomeAssistant) assert automation.entities_in_automation(hass, entity_id) == [] -async def test_extraction_functions(hass: HomeAssistant) -> None: +async def test_extraction_functions( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test extraction functions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + condition_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) + device_in_both = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")}, + ) + device_in_last = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:03")}, + ) + trigger_device_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:04")}, + ) + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) assert await async_setup_component( @@ -1652,7 +1678,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: }, { "domain": "light", - "device_id": "device-in-both", + "device_id": device_in_both.id, "entity_id": "light.bla", "type": "turn_on", }, @@ -1670,7 +1696,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "domain": "light", "type": "turned_on", "entity_id": "light.trigger_2", - "device_id": "trigger-device-2", + "device_id": trigger_device_2.id, }, { "platform": "tag", @@ -1702,7 +1728,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: ], "condition": { "condition": "device", - "device_id": "condition-device", + "device_id": condition_device.id, "domain": "light", "type": "is_on", "entity_id": "light.bla", @@ -1720,13 +1746,13 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: {"scene": "scene.hello"}, { "domain": "light", - "device_id": "device-in-both", + "device_id": device_in_both.id, "entity_id": "light.bla", "type": "turn_on", }, { "domain": "light", - "device_id": "device-in-last", + "device_id": device_in_last.id, "entity_id": "light.bla", "type": "turn_on", }, @@ -1755,7 +1781,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: ], "condition": { "condition": "device", - "device_id": "condition-device", + "device_id": condition_device.id, "domain": "light", "type": "is_on", "entity_id": "light.bla", @@ -1799,15 +1825,15 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "light.in_both", "light.in_first", } - assert set(automation.automations_with_device(hass, "device-in-both")) == { + assert set(automation.automations_with_device(hass, device_in_both.id)) == { "automation.test1", "automation.test2", } assert set(automation.devices_in_automation(hass, "automation.test2")) == { - "trigger-device-2", - "condition-device", - "device-in-both", - "device-in-last", + trigger_device_2.id, + condition_device.id, + device_in_both.id, + device_in_last.id, "device-trigger-event", "device-trigger-tag1", "device-trigger-tag2", diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index b25ab787791..c902caf31ae 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -234,6 +234,7 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -245,7 +246,14 @@ async def test_if_state( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -258,7 +266,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_bat_low", } @@ -277,7 +285,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_not_bat_low", } @@ -312,6 +320,7 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -323,7 +332,14 @@ async def test_if_state_legacy( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -336,7 +352,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_bat_low", } @@ -364,6 +380,7 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -379,7 +396,14 @@ async def test_if_fires_on_for_condition( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) with freeze_time(point1) as time_freeze: assert await async_setup_component( @@ -392,7 +416,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_not_bat_low", "for": {"seconds": 5}, diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 4b8318e2d79..47abb29ae86 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -235,6 +235,7 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -245,10 +246,17 @@ async def test_if_fires_on_state_change( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -260,7 +268,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "bat_low", }, @@ -284,7 +292,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "not_bat_low", }, @@ -329,6 +337,7 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -340,10 +349,17 @@ async def test_if_fires_on_state_change_with_for( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -355,7 +371,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, @@ -398,6 +414,7 @@ async def test_if_fires_on_state_change_with_for( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -409,10 +426,17 @@ async def test_if_fires_on_state_change_legacy( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -424,7 +448,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 765f7af3f62..dafba61d77a 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -153,6 +153,21 @@ async def test_flow_with_unsupported_version( assert result["errors"] == {"base": "unsupported_version"} +async def test_flow_with_auth_failure(hass: HomeAssistant, product_class_mock) -> None: + """Test that config flow works.""" + with product_class_mock as products_class: + products_class.async_from_host = AsyncMock( + side_effect=blebox_uniapi.error.UnauthorizedRequest + ) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, + ) + assert result["errors"] == {"base": "cannot_connect"} + + async def test_async_setup(hass: HomeAssistant) -> None: """Test async_setup (for coverage).""" assert await async_setup_component(hass, "blebox", {"host": "172.2.3.4"}) diff --git a/tests/components/blebox/test_helpers.py b/tests/components/blebox/test_helpers.py new file mode 100644 index 00000000000..bf355612f14 --- /dev/null +++ b/tests/components/blebox/test_helpers.py @@ -0,0 +1,20 @@ +"""Blebox helpers tests.""" + +from aiohttp.helpers import BasicAuth + +from homeassistant.components.blebox.helpers import get_maybe_authenticated_session +from homeassistant.core import HomeAssistant + + +async def test_get_maybe_authenticated_session_none(hass: HomeAssistant): + """Tests if session auth is None.""" + session = get_maybe_authenticated_session(hass=hass, username="", password="") + assert session.auth is None + + +async def test_get_maybe_authenticated_session_auth(hass: HomeAssistant): + """Tests if session have BasicAuth.""" + session = get_maybe_authenticated_session( + hass=hass, username="user", password="password" + ) + assert isinstance(session.auth, BasicAuth) diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index e7733147221..e2184df9820 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -197,7 +197,7 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert ATTR_BRIGHTNESS not in state.attributes + assert state.attributes[ATTR_BRIGHTNESS] is None @pytest.fixture(name="wlightbox_s") @@ -236,7 +236,7 @@ async def test_wlightbox_s_init(wlightbox_s, hass: HomeAssistant) -> None: color_modes = state.attributes[ATTR_SUPPORTED_COLOR_MODES] assert color_modes == [ColorMode.BRIGHTNESS] - assert ATTR_BRIGHTNESS not in state.attributes + assert state.attributes[ATTR_BRIGHTNESS] is None assert state.state == STATE_UNKNOWN device_registry = dr.async_get(hass) @@ -339,8 +339,8 @@ async def test_wlightbox_init(wlightbox, hass: HomeAssistant) -> None: color_modes = state.attributes[ATTR_SUPPORTED_COLOR_MODES] assert color_modes == [ColorMode.RGBW] - assert ATTR_BRIGHTNESS not in state.attributes - assert ATTR_RGBW_COLOR not in state.attributes + assert state.attributes[ATTR_BRIGHTNESS] is None + assert state.attributes[ATTR_RGBW_COLOR] is None assert state.state == STATE_UNKNOWN device_registry = dr.async_get(hass) @@ -487,7 +487,7 @@ async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None: ) state = hass.states.get(entity_id) - assert ATTR_RGBW_COLOR not in state.attributes + assert state.attributes[ATTR_RGBW_COLOR] is None assert state.state == STATE_OFF diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index 8b1e13aaa70..ab04499c827 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Blink config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from blinkpy.auth import LoginError from blinkpy.blinkpy import BlinkSetupError @@ -268,10 +268,10 @@ async def test_options_flow(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) - mock_auth = Mock( + mock_auth = AsyncMock( startup=Mock(return_value=True), check_key_required=Mock(return_value=False) ) - mock_blink = Mock() + mock_blink = AsyncMock(cameras=Mock(), sync=Mock()) with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( "homeassistant.components.blink.Blink", return_value=mock_blink @@ -293,7 +293,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], user_input={"scan_interval": 5}, ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"] == {"scan_interval": 5} await hass.async_block_till_done() diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index fc870f2bfe3..31d90a6e93d 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -42,7 +42,10 @@ from . import ( from tests.common import async_fire_time_changed, load_fixture -async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.parametrize("name_2", [None, "w"]) +async def test_remote_scanner( + hass: HomeAssistant, enable_bluetooth: None, name_2: str | None +) -> None: """Test the remote scanner base class merges advertisement_data.""" manager = _get_manager() @@ -61,12 +64,25 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No ) switchbot_device_2 = generate_ble_device( "44:44:33:11:23:45", - "w", + name_2, {}, rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( - local_name="wohand", + local_name=name_2, + service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], + service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01", 2: b"\x02"}, + rssi=-100, + ) + switchbot_device_3 = generate_ble_device( + "44:44:33:11:23:45", + "wohandlonger", + {}, + rssi=-100, + ) + switchbot_device_adv_3 = generate_advertisement_data( + local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 2: b"\x02"}, @@ -125,6 +141,15 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No "00000001-0000-1000-8000-00805f9b34fb", } + # The longer name should be used + scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3) + assert discovered_device.name == switchbot_device_3.name + + # Inject the shorter name / None again to make + # sure we always keep the longer name + scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2) + assert discovered_device.name == switchbot_device_3.name + cancel() unsetup() diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 765e2a9a612..0e8b2b54f06 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -157,6 +157,7 @@ async def test_diagnostics( }, "advertisement_tracker": { "intervals": {}, + "fallback_intervals": {}, "sources": {}, "timings": {}, }, @@ -328,6 +329,7 @@ async def test_diagnostics_macos( }, "advertisement_tracker": { "intervals": {}, + "fallback_intervals": {}, "sources": {"44:44:33:11:23:45": "local"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, @@ -520,6 +522,7 @@ async def test_diagnostics_remote_adapter( }, "advertisement_tracker": { "intervals": {}, + "fallback_intervals": {}, "sources": {"44:44:33:11:23:45": "esp32"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 5baff65f29a..8cc76e01d8c 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -112,6 +112,65 @@ GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( }, ) +GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_TEMP_CHANGE = PassiveBluetoothDataUpdate( + devices={ + None: DeviceInfo( + name="Test Device", model="Test Model", manufacturer="Test Manufacturer" + ), + }, + entity_data={ + PassiveBluetoothEntityKey("temperature", None): 15.5, + PassiveBluetoothEntityKey("pressure", None): 1234, + }, + entity_names={ + PassiveBluetoothEntityKey("temperature", None): "Temperature", + PassiveBluetoothEntityKey("pressure", None): "Pressure", + }, + entity_descriptions={ + PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( + key="pressure", + native_unit_of_measurement="hPa", + device_class=SensorDeviceClass.PRESSURE, + ), + }, +) + + +GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_DEVICE_NAME_AND_TEMP_CHANGE = ( + PassiveBluetoothDataUpdate( + devices={ + None: DeviceInfo( + name="Changed", model="Test Model", manufacturer="Test Manufacturer" + ), + }, + entity_data={ + PassiveBluetoothEntityKey("temperature", None): 15.5, + PassiveBluetoothEntityKey("pressure", None): 1234, + }, + entity_names={ + PassiveBluetoothEntityKey("temperature", None): "Temperature", + PassiveBluetoothEntityKey("pressure", None): "Pressure", + }, + entity_descriptions={ + PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( + key="pressure", + native_unit_of_measurement="hPa", + device_class=SensorDeviceClass.PRESSURE, + ), + }, + ) +) + async def test_basic_usage( hass: HomeAssistant, @@ -189,9 +248,9 @@ async def test_basic_usage( inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) - # Each listener should receive the same data - # since both match - assert len(entity_key_events) == 2 + # Only the all listener should receive the new data + # since temperature is not in the new data + assert len(entity_key_events) == 1 assert len(all_events) == 2 # On the second, the entities should already be created @@ -206,8 +265,130 @@ async def test_basic_usage( # Each listener should not trigger any more now # that they were cancelled + assert len(entity_key_events) == 1 + assert len(all_events) == 2 + assert len(mock_entity.mock_calls) == 2 + assert coordinator.available is True + + unregister_processor() + cancel_coordinator() + + +async def test_entity_key_is_dispatched_on_entity_key_change( + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + mock_bluetooth_adapters: None, +) -> None: + """Test entity key listeners are only dispatched on change.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + update_count = 0 + + @callback + def _mock_update_method( + service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + assert data == {"test": "data"} + nonlocal update_count + update_count += 1 + if update_count > 2: + return ( + GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_DEVICE_NAME_AND_TEMP_CHANGE + ) + if update_count > 1: + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_TEMP_CHANGE + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + + unregister_processor = coordinator.async_register_processor(processor) + cancel_coordinator = coordinator.async_start() + + entity_key = PassiveBluetoothEntityKey("temperature", None) + entity_key_events = [] + all_events = [] + mock_entity = MagicMock() + mock_add_entities = MagicMock() + + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) + + cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener( + _async_entity_key_listener, + entity_key, + ) + + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) + + cancel_listener = processor.async_add_listener( + _all_listener, + ) + + cancel_async_add_entities_listener = processor.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # Each listener should receive the same data + # since both match + assert len(entity_key_events) == 1 + assert len(all_events) == 1 + + # There should be 4 calls to create entities + assert len(mock_entity.mock_calls) == 2 + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + + # Both listeners should receive the new data + # since temperature IS in the new data assert len(entity_key_events) == 2 assert len(all_events) == 2 + + # On the second, the entities should already be created + # so the mock should not be called again + assert len(mock_entity.mock_calls) == 2 + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # All listeners should receive the data since + # the device name changed + assert len(entity_key_events) == 3 + assert len(all_events) == 3 + + # On the second, the entities should already be created + # so the mock should not be called again + assert len(mock_entity.mock_calls) == 2 + + cancel_async_add_entity_key_listener() + cancel_listener() + cancel_async_add_entities_listener() + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + + # Each listener should not trigger any more now + # that they were cancelled + assert len(entity_key_events) == 3 + assert len(all_events) == 3 assert len(mock_entity.mock_calls) == 2 assert coordinator.available is True @@ -897,9 +1078,9 @@ async def test_integration_with_entity( # Forth call with both primary and remote sensor entities does not add them again assert len(mock_add_entities.mock_calls) == 2 - # should not have triggered the entity key listener since there - # there is an update with the entity key - assert len(entity_key_events) == 3 + # should not have triggered the entity key listener humidity + # is not in the update + assert len(entity_key_events) == 2 entities = [ *mock_add_entities.mock_calls[0][1][0], @@ -1027,6 +1208,7 @@ async def test_integration_with_entity_without_a_device( assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature" assert entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "name": "Generic", } assert entity_one.entity_key == PassiveBluetoothEntityKey( @@ -1215,6 +1397,7 @@ async def test_integration_multiple_entity_platforms( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1231,6 +1414,7 @@ async def test_integration_multiple_entity_platforms( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1375,6 +1559,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1391,6 +1576,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1455,6 +1641,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1471,6 +1658,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1549,6 +1737,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1565,6 +1754,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 9cb0fdb8a5d..6cbd43b221b 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -726,7 +726,7 @@ async def test_brightness_support(hass: HomeAssistant) -> None: state = hass.states.get("light.name_1") assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -752,7 +752,7 @@ async def test_brightness_not_supported(hass: HomeAssistant) -> None: state = hass.states.get("light.name_1") assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 diff --git a/tests/components/brother/__init__.py b/tests/components/brother/__init__.py index 8e24c2d8058..3176fa7fc28 100644 --- a/tests/components/brother/__init__.py +++ b/tests/components/brother/__init__.py @@ -1,13 +1,16 @@ """Tests for Brother Printer integration.""" import json +import sys from unittest.mock import patch -from homeassistant.components.brother.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +if sys.version_info < (3, 12): + from homeassistant.components.brother.const import DOMAIN + async def init_integration( hass: HomeAssistant, skip_setup: bool = False diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py index 9e81cce9d12..558b3b8ac3e 100644 --- a/tests/components/brother/conftest.py +++ b/tests/components/brother/conftest.py @@ -1,9 +1,13 @@ """Test fixtures for brother.""" from collections.abc import Generator +import sys from unittest.mock import AsyncMock, patch import pytest +if sys.version_info >= (3, 12): + collect_ignore_glob = ["test_*.py"] + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: diff --git a/tests/components/brottsplatskartan/test_config_flow.py b/tests/components/brottsplatskartan/test_config_flow.py index dd3139dc2b9..f27139ad381 100644 --- a/tests/components/brottsplatskartan/test_config_flow.py +++ b/tests/components/brottsplatskartan/test_config_flow.py @@ -1,8 +1,6 @@ """Test the Brottsplatskartan config flow.""" from __future__ import annotations -from unittest.mock import patch - import pytest from homeassistant import config_entries @@ -11,8 +9,6 @@ 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") @@ -27,9 +23,7 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_AREA: "none", - }, + {}, ) await hass.async_block_till_done() @@ -55,7 +49,6 @@ async def test_form_location(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_AREA: "none", CONF_LOCATION: { CONF_LATITUDE: 59.32, CONF_LONGITUDE: 18.06, @@ -103,107 +96,3 @@ async def test_form_area(hass: HomeAssistant) -> 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/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 831f7811972..c1f8e26ccb2 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -957,6 +957,21 @@ async def test_v1_sensors( }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x54\x0C\x48\x65\x6C\x6C\x6F\x20\x57\x6F\x72\x6C\x64\x21", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_raw", + "friendly_name": "Test Device 18B2 Raw", + "expected_state": "48656c6c6f20576f726c6421", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( diff --git a/tests/components/button/test_device_action.py b/tests/components/button/test_device_action.py index 43e2d3f855f..3fefa580724 100644 --- a/tests/components/button/test_device_action.py +++ b/tests/components/button/test_device_action.py @@ -95,9 +95,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for press action.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -111,7 +123,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "press", }, @@ -131,10 +143,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for press action.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -148,7 +170,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "press", }, diff --git a/tests/components/button/test_device_trigger.py b/tests/components/button/test_device_trigger.py index 32f10044206..e231fc3ae19 100644 --- a/tests/components/button/test_device_trigger.py +++ b/tests/components/button/test_device_trigger.py @@ -105,10 +105,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, "unknown") @@ -121,7 +132,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "pressed", }, @@ -154,10 +165,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, "unknown") @@ -170,7 +192,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "pressed", }, diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index ddf089c10c0..f64cf699451 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -375,14 +375,16 @@ def _mocked_dav_client(*names, calendars=None): return client -def _mock_calendar(name): +def _mock_calendar(name, supported_components=None): calendar = Mock() events = [] for idx, event in enumerate(EVENTS): events.append(Event(None, "%d.ics" % idx, event, calendar, str(idx))) - + if supported_components is None: + supported_components = ["VEVENT"] calendar.search = MagicMock(return_value=events) calendar.name = name + calendar.get_supported_components = MagicMock(return_value=supported_components) return calendar @@ -1066,3 +1068,40 @@ async def test_get_events_custom_calendars( "rrule": None, } ] + + +async def test_calendar_components( + hass: HomeAssistant, +) -> None: + """Test that only calendars that support events are created.""" + calendars = [ + _mock_calendar("Calendar 1", supported_components=["VEVENT"]), + _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), + _mock_calendar("Calendar 3", supported_components=["VTODO"]), + # Fallback to allow when no components are supported to be conservative + _mock_calendar("Calendar 4", supported_components=[]), + ] + with patch( + "homeassistant.components.caldav.calendar.caldav.DAVClient", + return_value=_mocked_dav_client(calendars=calendars), + ): + assert await async_setup_component( + hass, "calendar", {"calendar": CALDAV_CONFIG} + ) + await hass.async_block_till_done() + + state = hass.states.get("calendar.calendar_1") + assert state.name == "Calendar 1" + assert state.state == STATE_OFF + + state = hass.states.get("calendar.calendar_2") + assert state.name == "Calendar 2" + assert state.state == STATE_OFF + + # No entity created for To-do only component + state = hass.states.get("calendar.calendar_3") + assert not state + + state = hass.states.get("calendar.calendar_4") + assert state.name == "Calendar 4" + assert state.state == STATE_OFF diff --git a/tests/components/calendar/snapshots/test_init.ambr b/tests/components/calendar/snapshots/test_init.ambr new file mode 100644 index 00000000000..7d48228193a --- /dev/null +++ b/tests/components/calendar/snapshots/test_init.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_list_events_service_duration[calendar.calendar_1-00:15:00] + dict({ + 'events': list([ + ]), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_1-01:00:00] + dict({ + 'events': list([ + dict({ + 'description': 'Future Description', + 'end': '2023-10-19T08:20:05-07:00', + 'location': 'Future Location', + 'start': '2023-10-19T07:20:05-07:00', + 'summary': 'Future Event', + }), + ]), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_2-00:15:00] + dict({ + 'events': list([ + dict({ + 'end': '2023-10-19T07:20:05-07:00', + 'start': '2023-10-19T06:20:05-07:00', + 'summary': 'Current Event', + }), + ]), + }) +# --- diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index e0fbbf0cdeb..ad83d039d73 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -8,6 +8,7 @@ from unittest.mock import patch from freezegun import freeze_time import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.bootstrap import async_setup_component @@ -368,7 +369,7 @@ async def test_create_event_service_invalid_params( date_fields: dict[str, Any], expected_error: type[Exception], error_match: str | None, -): +) -> None: """Test creating an event using the create_event service.""" await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) @@ -397,7 +398,10 @@ async def test_create_event_service_invalid_params( ], ) async def test_list_events_service( - hass: HomeAssistant, set_time_zone: None, start_time: str, end_time: str + hass: HomeAssistant, + set_time_zone: None, + start_time: str, + end_time: str, ) -> None: """Test listing events from the service call using exlplicit start and end time. @@ -433,21 +437,22 @@ async def test_list_events_service( @pytest.mark.parametrize( - ("entity", "duration", "expected_events"), + ("entity", "duration"), [ # Calendar 1 has an hour long event starting in 30 minutes. No events in the # next 15 minutes, but it shows up an hour from now. - ("calendar.calendar_1", "00:15:00", []), - ("calendar.calendar_1", "01:00:00", ["Future Event"]), + ("calendar.calendar_1", "00:15:00"), + ("calendar.calendar_1", "01:00:00"), # Calendar 2 has a active event right now - ("calendar.calendar_2", "00:15:00", ["Current Event"]), + ("calendar.calendar_2", "00:15:00"), ], ) +@pytest.mark.freeze_time("2023-10-19 13:50:05") async def test_list_events_service_duration( hass: HomeAssistant, entity: str, duration: str, - expected_events: list[str], + snapshot: SnapshotAssertion, ) -> None: """Test listing events using a time duration.""" await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) @@ -463,10 +468,7 @@ async def test_list_events_service_duration( blocking=True, return_response=True, ) - assert response - assert "events" in response - events = response["events"] - assert [event["summary"] for event in events] == expected_events + assert response == snapshot async def test_list_events_positive_duration(hass: HomeAssistant) -> None: diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index f56f499c935..8ef73ed4e51 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -143,9 +143,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -168,7 +180,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_hvac_mode", "hvac_mode": HVACMode.OFF, @@ -181,7 +193,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_preset_mode", "preset_mode": const.PRESET_AWAY, @@ -219,10 +231,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -245,7 +267,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_hvac_mode", "hvac_mode": HVACMode.OFF, diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index 33df78bf347..4dc365e59ee 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -147,10 +147,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -163,7 +174,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_hvac_mode", "hvac_mode": "cool", @@ -185,7 +196,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_preset_mode", "preset_mode": "away", @@ -257,10 +268,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -273,7 +295,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_hvac_mode", "hvac_mode": "cool", diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index c600e4004e8..59efb66ff65 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -147,10 +147,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -171,7 +182,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "hvac_mode_changed", "to": HVACMode.AUTO, @@ -185,7 +196,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_temperature_changed", "above": 20, @@ -199,7 +210,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "below": 10, @@ -257,10 +268,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -281,7 +303,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "hvac_mode_changed", "to": HVACMode.AUTO, diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 36955b0b0a9..10999b04bea 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,16 +1,20 @@ """Common stuff for Comelit SimpleHome tests.""" + from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT MOCK_CONFIG = { DOMAIN: { CONF_DEVICES: [ { CONF_HOST: "fake_host", - CONF_PIN: "1234", + CONF_PORT: 80, + CONF_PIN: 1234, } ] } } MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] + +FAKE_PIN = 5678 diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index 10f68f4d7c1..f2d59f46114 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -6,11 +6,11 @@ import pytest from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PIN +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_USER_DATA +from .const import FAKE_PIN, MOCK_USER_DATA from tests.common import MockConfigEntry @@ -39,7 +39,8 @@ async def test_user(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_PIN] == "1234" + assert result["data"][CONF_PORT] == 80 + assert result["data"][CONF_PIN] == 1234 assert not result["result"].unique_id await hass.async_block_till_done() @@ -66,6 +67,10 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> with patch( "aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect, + ), patch( + "aiocomelit.api.ComeliteSerialBridgeApi.logout", + ), patch( + "homeassistant.components.comelit.async_setup_entry" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA @@ -103,7 +108,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_PIN: "other_fake_pin", + CONF_PIN: FAKE_PIN, }, ) await hass.async_block_till_done() @@ -145,7 +150,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_PIN: "other_fake_pin", + CONF_PIN: FAKE_PIN, }, ) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 7d5db4603fe..360c78dd5a7 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -93,6 +93,9 @@ async def test_setup_integration_yaml( "payload_on": "1.0", "payload_off": "0", "value_template": "{{ value | multiply(0.1) }}", + "icon": ( + '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}' + ), } } ] @@ -105,6 +108,7 @@ async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> Non entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_ON + assert entity_state.attributes.get("icon") == "mdi:on" @pytest.mark.parametrize( @@ -304,7 +308,7 @@ async def test_updating_manually( await hass.async_block_till_done() assert called - called.clear + called.clear() await hass.services.async_call( HA_DOMAIN, diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index da2bf1f6dd9..388d0345cad 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -93,6 +93,7 @@ async def test_setup_integration_yaml( "command": "echo 50", "unit_of_measurement": "in", "value_template": "{{ value | multiply(0.1) }}", + "icon": "mdi:console", } } ] @@ -105,6 +106,7 @@ async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> Non entity_state = hass.states.get("sensor.test") assert entity_state assert float(entity_state.state) == 5 + assert entity_state.attributes.get("icon") == "mdi:console" @pytest.mark.parametrize( diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 4239e031893..3cc7ada49ba 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -798,6 +798,9 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: description_placeholders={"enabled": "Set to true to be true"}, ) + async def async_step_user(self, user_input=None): + raise NotImplementedError + return OptionsFlowHandler() mock_integration(hass, MockModule("test")) @@ -1271,6 +1274,9 @@ async def test_ignore_flow( await self.async_set_unique_id("mock-unique-id") return self.async_show_form(step_id="account") + async def async_step_account(self, user_input=None): + raise NotImplementedError + ws_client = await hass_ws_client(hass) with patch.dict(HANDLERS, {"test": TestFlow}): diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index a92b2a353ef..87bb9cc9409 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -63,6 +63,7 @@ async def test_list_devices( "model": "model", "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, }, @@ -79,6 +80,7 @@ async def test_list_devices( "model": "model", "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": dev1, }, @@ -108,6 +110,7 @@ async def test_list_devices( "model": "model", "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, } diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 1677b254ff6..c75c96ca59b 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -283,3 +283,13 @@ async def test_shopping_list_add_item( assert result.response.speech == { "plain": {"speech": "Added apples", "extra_data": None} } + + +async def test_nevermind_item(hass: HomeAssistant, init_components) -> None: + """Test HassNevermind intent through the default agent.""" + result = await conversation.async_converse(hass, "nevermind", None, Context()) + assert result.response.intent is not None + assert result.response.intent.intent_type == intent.INTENT_NEVERMIND + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert not result.response.speech diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index 0cc6716bd3c..c476f78702e 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -351,11 +351,20 @@ async def test_get_action_capabilities_set_tilt_pos( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -366,7 +375,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open", }, @@ -375,7 +384,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_close"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "close", }, @@ -384,7 +393,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_stop"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "stop", }, @@ -429,11 +438,20 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -444,7 +462,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open", }, @@ -467,11 +485,20 @@ async def test_action_legacy( async def test_action_tilt( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover tilt actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -482,7 +509,7 @@ async def test_action_tilt( "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open_tilt", }, @@ -491,7 +518,7 @@ async def test_action_tilt( "trigger": {"platform": "event", "event_type": "test_event_close"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "close_tilt", }, @@ -529,11 +556,20 @@ async def test_action_tilt( async def test_action_set_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover set position actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -547,7 +583,7 @@ async def test_action_set_position( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_position", "position": 25, @@ -560,7 +596,7 @@ async def test_action_set_position( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_tilt_position", "position": 75, diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index bfde3a0b514..2dcc719f35f 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -355,10 +355,21 @@ async def test_get_condition_capabilities_set_tilt_pos( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OPEN) @@ -373,7 +384,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_open", } @@ -395,7 +406,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_closed", } @@ -417,7 +428,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_opening", } @@ -439,7 +450,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_closing", } @@ -487,10 +498,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OPEN) @@ -505,7 +527,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_open", } @@ -533,6 +555,7 @@ async def test_if_state_legacy( async def test_if_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, @@ -545,7 +568,14 @@ async def test_if_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -559,7 +589,7 @@ async def test_if_position( "conditions": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_position", "above": 45, @@ -593,7 +623,7 @@ async def test_if_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_position", "below": 90, @@ -616,7 +646,7 @@ async def test_if_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_position", "above": 45, @@ -686,6 +716,7 @@ async def test_if_position( async def test_if_tilt_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, @@ -698,7 +729,14 @@ async def test_if_tilt_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -712,7 +750,7 @@ async def test_if_tilt_position( "conditions": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_tilt_position", "above": 45, @@ -746,7 +784,7 @@ async def test_if_tilt_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_tilt_position", "below": 90, @@ -769,7 +807,7 @@ async def test_if_tilt_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_tilt_position", "above": 45, diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index fc82bbd1499..e464ff87c3f 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -378,10 +378,21 @@ async def test_get_trigger_capabilities_set_tilt_pos( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for state triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLOSED) @@ -394,7 +405,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "opened", }, @@ -416,7 +427,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "closed", }, @@ -438,7 +449,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "opening", }, @@ -460,7 +471,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "closing", }, @@ -520,10 +531,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for state triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLOSED) @@ -536,7 +558,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "opened", }, @@ -569,10 +591,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLOSED) @@ -585,7 +618,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "opened", "for": {"seconds": 5}, @@ -627,6 +660,7 @@ async def test_if_fires_on_state_change_with_for( async def test_if_fires_on_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -638,7 +672,14 @@ async def test_if_fires_on_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -650,7 +691,7 @@ async def test_if_fires_on_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "position", "above": 45, @@ -675,7 +716,7 @@ async def test_if_fires_on_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "position", "below": 90, @@ -700,7 +741,7 @@ async def test_if_fires_on_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "position", "above": 45, @@ -773,6 +814,7 @@ async def test_if_fires_on_position( async def test_if_fires_on_tilt_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -784,7 +826,14 @@ async def test_if_fires_on_tilt_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -796,7 +845,7 @@ async def test_if_fires_on_tilt_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "tilt_position", "above": 45, @@ -821,7 +870,7 @@ async def test_if_fires_on_tilt_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "tilt_position", "below": 90, @@ -846,7 +895,7 @@ async def test_if_fires_on_tilt_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "tilt_position", "above": 45, diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 63e4e8351b4..357371e4853 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -185,7 +185,25 @@ async def test_no_lights_or_groups( "entity_id": "light.lidl_xmas_light", "state": STATE_ON, "attributes": { - ATTR_EFFECT_LIST: [EFFECT_COLORLOOP], + ATTR_EFFECT_LIST: [ + EFFECT_COLORLOOP, + "carnival", + "collide", + "fading", + "fireworks", + "flag", + "glow", + "rainbow", + "snake", + "snow", + "sparkles", + "steady", + "strobe", + "twinkle", + "updown", + "vintage", + "waves", + ], ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_COLOR_MODE: ColorMode.HS, ATTR_BRIGHTNESS: 25, @@ -1184,9 +1202,9 @@ async def test_non_color_light_reports_color( await hass.async_block_till_done() # Bug is fixed if we reach this point, but device won't have neither color temp nor color - with pytest.raises(KeyError): - assert hass.states.get("light.group").attributes[ATTR_COLOR_TEMP] - assert hass.states.get("light.group").attributes[ATTR_HS_COLOR] + with pytest.raises(AssertionError): + assert hass.states.get("light.group").attributes.get(ATTR_COLOR_TEMP) is None + assert hass.states.get("light.group").attributes.get(ATTR_HS_COLOR) is None async def test_verify_group_supported_features( diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index fa87c439a4d..69e385ce242 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -198,6 +198,28 @@ async def test_set_target_temp_range_bad_attr(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 24.0 +async def test_set_temp_with_hvac_mode(hass: HomeAssistant) -> None: + """Test the setting of the hvac_mode in set_temperature.""" + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get(ATTR_TEMPERATURE) == 21 + assert state.state == HVACMode.COOL + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_CLIMATE, + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 23 + + async def test_set_target_humidity_bad_attr(hass: HomeAssistant) -> None: """Test setting the target humidity without required attribute.""" state = hass.states.get(ENTITY_CLIMATE) diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 9c1b84313f6..58a8c99ea3c 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -1,4 +1,6 @@ """Test cases around the demo fan platform.""" +from unittest.mock import patch + import pytest from homeassistant.components import fan @@ -15,6 +17,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -31,8 +34,18 @@ FANS_WITH_PRESET_MODES = FULL_FAN_ENTITY_IDS + [ PERCENTAGE_MODEL_FANS = ["fan.percentage_full_fan", "fan.percentage_limited_fan"] +@pytest.fixture +async def fan_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.FAN], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_comp(hass, disable_platforms): +async def setup_comp(hass: HomeAssistant, fan_only: None): """Initialize components.""" assert await async_setup_component(hass, fan.DOMAIN, {"fan": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_humidifier.py b/tests/components/demo/test_humidifier.py index 13aeb5724b9..97647f0a90f 100644 --- a/tests/components/demo/test_humidifier.py +++ b/tests/components/demo/test_humidifier.py @@ -1,5 +1,7 @@ """The tests for the demo humidifier component.""" +from unittest.mock import patch + import pytest import voluptuous as vol @@ -22,6 +24,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -31,8 +34,18 @@ ENTITY_HYGROSTAT = "humidifier.hygrostat" ENTITY_HUMIDIFIER = "humidifier.humidifier" +@pytest.fixture +async def humidifier_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.HUMIDIFIER], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_humidifier(hass, disable_platforms): +async def setup_demo_humidifier(hass: HomeAssistant, humidifier_only: None): """Initialize setup demo humidifier.""" assert await async_setup_component( hass, DOMAIN, {"humidifier": {"platform": "demo"}} diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index 377f9f2d765..f72f5b01c19 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -15,7 +15,7 @@ from homeassistant.components.lock import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,8 +27,18 @@ POORLY_INSTALLED = "lock.poorly_installed_door" OPENABLE_LOCK = "lock.openable_lock" +@pytest.fixture +async def lock_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.LOCK], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_comp(hass, disable_platforms): +async def setup_comp(hass: HomeAssistant, lock_only: None): """Set up demo component.""" assert await async_setup_component( hass, LOCK_DOMAIN, {LOCK_DOMAIN: {"platform": DOMAIN}} diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index 1681cdb9101..b1bd77a74a1 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -13,9 +13,10 @@ from homeassistant.const import ( STATE_OFF, STATE_PAUSED, STATE_PLAYING, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION +from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION, _make_key from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator @@ -26,6 +27,11 @@ TEST_ENTITY_ID = "media_player.walkman" @pytest.fixture(autouse=True) def autouse_disable_platforms(disable_platforms): """Auto use the disable_platforms fixture.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.MEDIA_PLAYER], + ): + yield @pytest.fixture(name="mock_media_seek") @@ -477,7 +483,7 @@ async def test_media_image_proxy( def detach(self): """Test websession detach.""" - hass.data[DATA_CLIENTSESSION] = MockWebsession() + hass.data[DATA_CLIENTSESSION] = {_make_key(): MockWebsession()} state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_PLAYING diff --git a/tests/components/demo/test_remote.py b/tests/components/demo/test_remote.py index d1e5b4007ca..5fafffae372 100644 --- a/tests/components/demo/test_remote.py +++ b/tests/components/demo/test_remote.py @@ -1,4 +1,6 @@ """The tests for the demo remote component.""" +from unittest.mock import patch + import pytest import homeassistant.components.remote as remote @@ -9,6 +11,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,8 +20,18 @@ ENTITY_ID = "remote.remote_one" SERVICE_SEND_COMMAND = "send_command" +@pytest.fixture +async def remote_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.REMOTE], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_component(hass, disable_platforms): +async def setup_component(hass: HomeAssistant, remote_only: None): """Initialize components.""" assert await async_setup_component( hass, remote.DOMAIN, {"remote": {"platform": "demo"}} diff --git a/tests/components/demo/test_siren.py b/tests/components/demo/test_siren.py index 8051aaf5b20..1434248599c 100644 --- a/tests/components/demo/test_siren.py +++ b/tests/components/demo/test_siren.py @@ -16,6 +16,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -24,8 +25,18 @@ ENTITY_SIREN = "siren.siren" ENTITY_SIREN_WITH_ALL_FEATURES = "siren.siren_with_all_features" +@pytest.fixture +async def siren_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.SIREN], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_siren(hass, disable_platforms): +async def setup_demo_siren(hass: HomeAssistant, siren_only: None): """Initialize setup demo siren.""" assert await async_setup_component(hass, DOMAIN, {"siren": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index 711d0217f2d..cc0fcfeb2d2 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -1,5 +1,6 @@ """The tests for the Demo vacuum platform.""" from datetime import timedelta +from unittest.mock import patch import pytest @@ -35,6 +36,7 @@ from homeassistant.const import ( CONF_PLATFORM, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -52,8 +54,18 @@ ENTITY_VACUUM_NONE = f"{DOMAIN}.{DEMO_VACUUM_NONE}".lower() ENTITY_VACUUM_STATE = f"{DOMAIN}.{DEMO_VACUUM_STATE}".lower() +@pytest.fixture +async def vacuum_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.VACUUM], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_vacuum(hass, disable_platforms): +async def setup_demo_vacuum(hass: HomeAssistant, vacuum_only: None): """Initialize setup demo vacuum.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index cc91f57d872..6b133297e34 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -1,8 +1,11 @@ """The tests for the demo water_heater component.""" +from unittest.mock import patch + import pytest import voluptuous as vol from homeassistant.components import water_heater +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -13,8 +16,18 @@ ENTITY_WATER_HEATER = "water_heater.demo_water_heater" ENTITY_WATER_HEATER_CELSIUS = "water_heater.demo_water_heater_celsius" +@pytest.fixture +async def water_heater_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.WATER_HEATER], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_comp(hass, disable_platforms): +async def setup_comp(hass: HomeAssistant, water_heater_only: None): """Set up demo component.""" hass.config.units = US_CUSTOMARY_SYSTEM assert await async_setup_component( diff --git a/tests/components/demo/test_weather.py b/tests/components/demo/test_weather.py index ced801a4d46..bb91535192c 100644 --- a/tests/components/demo/test_weather.py +++ b/tests/components/demo/test_weather.py @@ -1,6 +1,7 @@ """The tests for the demo weather component.""" import datetime from typing import Any +from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -15,7 +16,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, ) -from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM @@ -23,7 +24,17 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from tests.typing import WebSocketGenerator -async def test_attributes(hass: HomeAssistant, disable_platforms) -> None: +@pytest.fixture +async def weather_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.WEATHER], + ): + yield + + +async def test_attributes(hass: HomeAssistant, weather_only) -> None: """Test weather attributes.""" assert await async_setup_component( hass, weather.DOMAIN, {"weather": {"platform": "demo"}} @@ -115,7 +126,7 @@ async def test_forecast( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, - disable_platforms: None, + weather_only: None, forecast_type: str, expected_forecast: list[dict[str, Any]], ) -> None: diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index 5be99026c77..bc440723df2 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -33,7 +33,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "round": 1, "source": input_sensor_entity_id, "time_window": {"seconds": 0}, - "unit_prefix": "none", "unit_time": "min", }, ) @@ -47,7 +46,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "round": 1.0, "source": "sensor.input", "time_window": {"seconds": 0.0}, - "unit_prefix": "none", "unit_time": "min", } assert len(mock_setup_entry.mock_calls) == 1 @@ -59,7 +57,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "round": 1.0, "source": "sensor.input", "time_window": {"seconds": 0.0}, - "unit_prefix": "none", "unit_time": "min", } assert config_entry.title == "My derivative" @@ -111,7 +108,6 @@ async def test_options(hass: HomeAssistant, platform) -> None: user_input={ "round": 2.0, "time_window": {"seconds": 10.0}, - "unit_prefix": "none", "unit_time": "h", }, ) @@ -121,7 +117,6 @@ async def test_options(hass: HomeAssistant, platform) -> None: "round": 2.0, "source": "sensor.input", "time_window": {"seconds": 10.0}, - "unit_prefix": "none", "unit_time": "h", } assert config_entry.data == {} @@ -130,7 +125,6 @@ async def test_options(hass: HomeAssistant, platform) -> None: "round": 2.0, "source": "sensor.input", "time_window": {"seconds": 10.0}, - "unit_prefix": "none", "unit_time": "h", } assert config_entry.title == "My derivative" diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 74150af67ae..457b7ccbf9b 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,6 +1,7 @@ """The test for light device automation.""" from unittest.mock import AsyncMock, Mock, patch +import attr import pytest from pytest_unordered import unordered import voluptuous as vol @@ -13,7 +14,7 @@ from homeassistant.components.device_automation import ( toggle_entity, ) from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import 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 @@ -31,6 +32,13 @@ from tests.common import ( from tests.typing import WebSocketGenerator +@attr.s(frozen=True) +class MockDeviceEntry(dr.DeviceEntry): + """Device Registry Entry with fixed UUID.""" + + id: str = attr.ib(default="very_unique") + + @pytest.fixture(autouse=True, name="stub_blueprint_populate") def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" @@ -908,7 +916,11 @@ async def test_automation_with_non_existing_integration( async def test_automation_with_device_action( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fake_integration + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration, ) -> None: """Test automation with a device action.""" @@ -916,6 +928,16 @@ async def test_automation_with_device_action( module = module_cache["fake_integration.device_action"] module.async_call_action_from_config = AsyncMock() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -924,9 +946,9 @@ async def test_automation_with_device_action( "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, "action": { - "device_id": "", + "device_id": device_entry.id, "domain": "fake_integration", - "entity_id": "blah.blah", + "entity_id": entity_entry.id, "type": "turn_on", }, } @@ -999,7 +1021,11 @@ async def test_automation_with_integration_without_device_action( async def test_automation_with_device_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fake_integration + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration, ) -> None: """Test automation with a device condition.""" @@ -1007,6 +1033,16 @@ async def test_automation_with_device_condition( module = module_cache["fake_integration.device_condition"] module.async_condition_from_config = Mock() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1016,9 +1052,9 @@ async def test_automation_with_device_condition( "trigger": {"platform": "event", "event_type": "test_event1"}, "condition": { "condition": "device", - "device_id": "none", + "device_id": device_entry.id, "domain": "fake_integration", - "entity_id": "blah.blah", + "entity_id": entity_entry.id, "type": "is_on", }, "action": {"service": "test.automation", "entity_id": "hello.world"}, @@ -1098,7 +1134,11 @@ async def test_automation_with_integration_without_device_condition( async def test_automation_with_device_trigger( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fake_integration + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration, ) -> None: """Test automation with a device trigger.""" @@ -1106,6 +1146,16 @@ async def test_automation_with_device_trigger( module = module_cache["fake_integration.device_trigger"] module.async_attach_trigger = AsyncMock() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1114,9 +1164,9 @@ async def test_automation_with_device_trigger( "alias": "hello", "trigger": { "platform": "device", - "device_id": "none", + "device_id": device_entry.id, "domain": "fake_integration", - "entity_id": "blah.blah", + "entity_id": entity_entry.id, "type": "turned_off", }, "action": {"service": "test.automation", "entity_id": "hello.world"}, @@ -1198,10 +1248,60 @@ async def test_automation_with_integration_without_device_trigger( ) +BAD_AUTOMATIONS = [ + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "wrong"}, + "required key not provided @ data{path}['domain']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "very_unique", "domain": "light", "entity_id": "wrong"}, + "Unknown entity 'wrong'", + ), +] + +BAD_TRIGGERS = BAD_CONDITIONS = BAD_AUTOMATIONS + [ + ( + {"domain": "light"}, + "required key not provided @ data{path}['device_id']", + ) +] + + +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("action", "expected_error"), BAD_AUTOMATIONS) async def test_automation_with_bad_action( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1209,18 +1309,33 @@ async def test_automation_with_bad_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": {"device_id": "", "domain": "light"}, + "action": action, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition_action( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1228,38 +1343,32 @@ async def test_automation_with_bad_condition_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": {"condition": "device", "device_id": "", "domain": "light"}, + "action": {"condition": "device"} | condition, } }, ) - assert "required key not provided" in caplog.text - - -async def test_automation_with_bad_condition_missing_domain( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test automation with bad device condition.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "device_id": "hello.device"}, - "action": {"service": "test.automation", "entity_id": "hello.world"}, - } - }, - ) - - assert "required key not provided @ data['condition'][0]['domain']" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1267,13 +1376,13 @@ async def test_automation_with_bad_condition( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "domain": "light"}, + "condition": {"condition": "device"} | condition, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['condition'][0]") in caplog.text @pytest.fixture @@ -1283,16 +1392,29 @@ def calls(hass): async def test_automation_with_sub_condition( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + calls, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test automation with device condition under and/or conditions.""" DOMAIN = "light" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - ent1, ent2, ent3 = platform.ENTITIES + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry1 = entity_registry.async_get_or_create( + "fake_integration", "test", "0001", device_id=device_entry.id + ) + entity_entry2 = entity_registry.async_get_or_create( + "fake_integration", "test", "0002", device_id=device_entry.id + ) + + hass.states.async_set(entity_entry1.entity_id, STATE_ON) + hass.states.async_set(entity_entry2.entity_id, STATE_OFF) assert await async_setup_component( hass, @@ -1308,15 +1430,15 @@ async def test_automation_with_sub_condition( { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry1.id, "type": "is_on", }, { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent2.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry2.id, "type": "is_on", }, ], @@ -1339,15 +1461,15 @@ async def test_automation_with_sub_condition( { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry1.id, "type": "is_on", }, { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent2.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry2.id, "type": "is_on", }, ], @@ -1365,8 +1487,8 @@ async def test_automation_with_sub_condition( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - assert hass.states.get(ent2.entity_id).state == STATE_OFF + assert hass.states.get(entity_entry1.entity_id).state == STATE_ON + assert hass.states.get(entity_entry2.entity_id).state == STATE_OFF assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -1374,18 +1496,18 @@ async def test_automation_with_sub_condition( assert len(calls) == 1 assert calls[0].data["some"] == "or event - test_event1" - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entity_entry1.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 - hass.states.async_set(ent2.entity_id, STATE_ON) + hass.states.async_set(entity_entry2.entity_id, STATE_ON) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "or event - test_event1" - hass.states.async_set(ent1.entity_id, STATE_ON) + hass.states.async_set(entity_entry1.entity_id, STATE_ON) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 4 @@ -1394,10 +1516,24 @@ async def test_automation_with_sub_condition( ) +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_sub_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition under and/or conditions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1407,33 +1543,48 @@ async def test_automation_with_bad_sub_condition( "trigger": {"platform": "event", "event_type": "test_event1"}, "condition": { "condition": "and", - "conditions": [{"condition": "device", "domain": "light"}], + "conditions": [{"condition": "device"} | condition], }, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + path = "['condition'][0]['conditions'][0]" + assert expected_error.format(path=path) in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("trigger", "expected_error"), BAD_TRIGGERS) async def test_automation_with_bad_trigger( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + trigger: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device trigger.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, { automation.DOMAIN: { "alias": "hello", - "trigger": {"platform": "device", "domain": "light"}, + "trigger": {"platform": "device"} | trigger, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="") in caplog.text async def test_websocket_device_not_found( diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index f02704cdc13..30c9e5b542e 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -4,12 +4,13 @@ from datetime import timedelta import pytest import homeassistant.components.automation as automation -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -24,22 +25,27 @@ def calls(hass): async def test_if_fires_on_state_change( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing. This is a sanity test for the toggle entity device automation helper, this is tested by each integration too. """ - platform = getattr(hass.components, "test.switch") - - platform.init() - assert await async_setup_component( - hass, "switch", {"switch": {CONF_PLATFORM: "test"}} + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "switch", "test", "5678", device_id=device_entry.id ) - await hass.async_block_till_done() - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -50,8 +56,8 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": "turned_on", }, "action": { @@ -74,8 +80,8 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": "turned_off", }, "action": { @@ -98,8 +104,8 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": "changed_states", }, "action": { @@ -122,40 +128,46 @@ async def test_if_fires_on_state_change( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 2 assert {calls[0].data["some"], calls[1].data["some"]} == { - f"turn_off device - {ent1.entity_id} - on - off - None", - f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + f"turn_off device - {entry.entity_id} - on - off - None", + f"turn_on_or_off device - {entry.entity_id} - on - off - None", } - hass.states.async_set(ent1.entity_id, STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 4 assert {calls[2].data["some"], calls[3].data["some"]} == { - f"turn_on device - {ent1.entity_id} - off - on - None", - f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + f"turn_on device - {entry.entity_id} - off - on - None", + f"turn_on_or_off device - {entry.entity_id} - off - on - None", } @pytest.mark.parametrize("trigger", ["turned_off", "changed_states"]) async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls, enable_custom_integrations: None, trigger + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, + trigger, ) -> None: """Test for triggers firing with delay.""" - platform = getattr(hass.components, "test.switch") - - platform.init() - assert await async_setup_component( - hass, "switch", {"switch": {CONF_PLATFORM: "test"}} + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "switch", "test", "5678", device_id=device_entry.id ) - await hass.async_block_till_done() - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -166,8 +178,8 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": trigger, "for": {"seconds": 5}, }, @@ -191,10 +203,10 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -202,5 +214,5 @@ async def test_if_fires_on_state_change_with_for( assert len(calls) == 1 await hass.async_block_till_done() assert calls[0].data["some"] == "turn_off device - {} - on - off - 0:00:05".format( - ent1.entity_id + entry.entity_id ) diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 008a7eb75c4..f550b803fda 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -110,10 +110,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_HOME) @@ -128,7 +139,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_home", } @@ -150,7 +161,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_not_home", } @@ -184,10 +195,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_HOME) @@ -202,7 +224,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_home", } diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index 75209ec607b..3e19570ebcb 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -142,10 +142,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_zone_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for enter and leave triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -162,7 +173,7 @@ async def test_if_fires_on_zone_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "enters", "zone": "zone.test", @@ -186,7 +197,7 @@ async def test_if_fires_on_zone_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "leaves", "zone": "zone.test", @@ -238,10 +249,21 @@ async def test_if_fires_on_zone_change( async def test_if_fires_on_zone_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for enter and leave triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -258,7 +280,7 @@ async def test_if_fires_on_zone_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "enters", "zone": "zone.test", diff --git a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr index 0c86cc94321..58cfc407a77 100644 --- a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': 'Door', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Test', @@ -79,6 +80,7 @@ 'original_icon': None, 'original_name': 'Overload', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Overload', @@ -121,6 +123,7 @@ 'original_icon': None, 'original_name': 'Button 1', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Test_1', diff --git a/tests/components/devolo_home_control/snapshots/test_climate.ambr b/tests/components/devolo_home_control/snapshots/test_climate.ambr index e0066a10656..0e7c5ba547e 100644 --- a/tests/components/devolo_home_control/snapshots/test_climate.ambr +++ b/tests/components/devolo_home_control/snapshots/test_climate.ambr @@ -51,6 +51,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'Test', diff --git a/tests/components/devolo_home_control/snapshots/test_cover.ambr b/tests/components/devolo_home_control/snapshots/test_cover.ambr index b2872d0c912..69d1eea4275 100644 --- a/tests/components/devolo_home_control/snapshots/test_cover.ambr +++ b/tests/components/devolo_home_control/snapshots/test_cover.ambr @@ -38,6 +38,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.Blinds', diff --git a/tests/components/devolo_home_control/snapshots/test_light.ambr b/tests/components/devolo_home_control/snapshots/test_light.ambr index 81c1e9b4293..cc02e0a680b 100644 --- a/tests/components/devolo_home_control/snapshots/test_light.ambr +++ b/tests/components/devolo_home_control/snapshots/test_light.ambr @@ -45,6 +45,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Dimmer:Test', @@ -97,6 +98,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Dimmer:Test', diff --git a/tests/components/devolo_home_control/snapshots/test_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_sensor.ambr index cb97ce77af0..0b7edcbd3ea 100644 --- a/tests/components/devolo_home_control/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_sensor.ambr @@ -40,6 +40,7 @@ 'original_icon': None, 'original_name': 'Battery level', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.BatterySensor:Test', @@ -87,6 +88,7 @@ 'original_icon': None, 'original_name': 'Current consumption', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Meter:Test_current', @@ -134,6 +136,7 @@ 'original_icon': None, 'original_name': 'Total consumption', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Meter:Test_total', @@ -181,6 +184,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.MultiLevelSensor:Test', diff --git a/tests/components/devolo_home_control/snapshots/test_siren.ambr b/tests/components/devolo_home_control/snapshots/test_siren.ambr index df1d514a11d..f699090c8cf 100644 --- a/tests/components/devolo_home_control/snapshots/test_siren.ambr +++ b/tests/components/devolo_home_control/snapshots/test_siren.ambr @@ -43,6 +43,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', @@ -93,6 +94,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', @@ -143,6 +145,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', diff --git a/tests/components/devolo_home_control/snapshots/test_switch.ambr b/tests/components/devolo_home_control/snapshots/test_switch.ambr index 4aa95944be0..fffe89337e7 100644 --- a/tests/components/devolo_home_control/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_control/snapshots/test_switch.ambr @@ -35,6 +35,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.BinarySwitch:Test', diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py index 80d1348cf0f..612df4da2e0 100644 --- a/tests/components/devolo_home_network/mock.py +++ b/tests/components/devolo_home_network/mock.py @@ -31,12 +31,18 @@ class MockDevice(Device): ) -> None: """Bring mock in a well defined state.""" super().__init__(ip, zeroconf_instance) + self._firmware_version = DISCOVERY_INFO.properties["FirmwareVersion"] self.reset() @property def firmware_version(self) -> str: """Mock firmware version currently installed.""" - return DISCOVERY_INFO.properties["FirmwareVersion"] + return self._firmware_version + + @firmware_version.setter + def firmware_version(self, version: str) -> None: + """Mock firmware version currently installed.""" + self._firmware_version = version async def async_connect( self, session_instance: httpx.AsyncClient | None = None @@ -49,6 +55,7 @@ class MockDevice(Device): def reset(self): """Reset mock to starting point.""" + self._firmware_version = DISCOVERY_INFO.properties["FirmwareVersion"] self.async_disconnect = AsyncMock() self.device = DeviceApi(IP, None, DISCOVERY_INFO) self.device.async_check_firmware_available = AsyncMock( diff --git a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr index f247f2dc1f0..985fc64146f 100644 --- a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr @@ -37,6 +37,7 @@ 'original_icon': 'mdi:router-network', 'original_name': 'Connected to router', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'connected_to_router', 'unique_id': '1234567890_connected_to_router', diff --git a/tests/components/devolo_home_network/snapshots/test_button.ambr b/tests/components/devolo_home_network/snapshots/test_button.ambr index a124ef57693..f00bb345aeb 100644 --- a/tests/components/devolo_home_network/snapshots/test_button.ambr +++ b/tests/components/devolo_home_network/snapshots/test_button.ambr @@ -79,6 +79,7 @@ 'original_icon': 'mdi:led-on', 'original_name': 'Identify device with a blinking LED', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'identify', 'unique_id': '1234567890_identify', @@ -165,6 +166,7 @@ 'original_icon': None, 'original_name': 'Restart device', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'restart', 'unique_id': '1234567890_restart', @@ -251,6 +253,7 @@ 'original_icon': 'mdi:plus-network-outline', 'original_name': 'Start PLC pairing', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pairing', 'unique_id': '1234567890_pairing', @@ -337,6 +340,7 @@ 'original_icon': 'mdi:wifi-plus', 'original_name': 'Start WPS', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_wps', 'unique_id': '1234567890_start_wps', diff --git a/tests/components/devolo_home_network/snapshots/test_image.ambr b/tests/components/devolo_home_network/snapshots/test_image.ambr index b00f73ca116..e6ca9e4fad5 100644 --- a/tests/components/devolo_home_network/snapshots/test_image.ambr +++ b/tests/components/devolo_home_network/snapshots/test_image.ambr @@ -23,6 +23,7 @@ 'original_icon': None, 'original_name': 'Guest Wifi credentials as QR code', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'image_guest_wifi', 'unique_id': '1234567890_image_guest_wifi', diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr new file mode 100644 index 00000000000..f2c27183945 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_setup_entry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.0.2.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'devolo_home_network', + '1234567890', + ), + }), + 'is_new': False, + 'manufacturer': 'devolo', + 'model': 'dLAN pro 1200+ WiFi ac', + 'name': 'Mock Title', + 'name_by_user': None, + 'serial_number': '1234567890', + 'suggested_area': None, + 'sw_version': '5.6.1', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 241313965c4..88eb46d57e8 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': 'mdi:lan', 'original_name': 'Connected PLC devices', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'connected_plc_devices', 'unique_id': '1234567890_connected_plc_devices', @@ -82,6 +83,7 @@ 'original_icon': 'mdi:wifi', 'original_name': 'Connected Wifi clients', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'connected_wifi_clients', 'unique_id': '1234567890_connected_wifi_clients', @@ -125,6 +127,7 @@ 'original_icon': 'mdi:wifi-marker', 'original_name': 'Neighboring Wifi networks', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'neighboring_wifi_networks', 'unique_id': '1234567890_neighboring_wifi_networks', diff --git a/tests/components/devolo_home_network/snapshots/test_switch.ambr b/tests/components/devolo_home_network/snapshots/test_switch.ambr index 600c9478035..4d268b21317 100644 --- a/tests/components/devolo_home_network/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_network/snapshots/test_switch.ambr @@ -122,6 +122,7 @@ 'original_icon': 'mdi:wifi', 'original_name': 'Enable guest Wifi', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'switch_guest_wifi', 'unique_id': '1234567890_switch_guest_wifi', @@ -165,6 +166,7 @@ 'original_icon': 'mdi:led-off', 'original_name': 'Enable LEDs', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'switch_leds', 'unique_id': '1234567890_switch_leds', diff --git a/tests/components/devolo_home_network/snapshots/test_update.ambr b/tests/components/devolo_home_network/snapshots/test_update.ambr new file mode 100644 index 00000000000..6dfba2de9c1 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_update.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_update_firmware + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/devolo_home_network/icon.png', + 'friendly_name': 'Mock Title Firmware', + 'in_progress': False, + 'installed_version': '5.6.1', + 'latest_version': '5.6.2', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.mock_title_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_firmware.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.mock_title_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'regular_firmware', + 'unique_id': '1234567890_regular_firmware', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index b8fb491e1ec..ef7c4b2bbba 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -68,8 +68,8 @@ async def test_guest_wifi_qr( # Emulate device failure mock_device.device.async_get_wifi_guest_access.side_effect = DeviceUnavailable() - freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -80,8 +80,8 @@ async def test_guest_wifi_qr( mock_device.device.async_get_wifi_guest_access = AsyncMock( return_value=GUEST_WIFI_CHANGED ) - freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 3c207a1aaef..e34af0dcbaf 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import patch from devolo_plc_api.exceptions.device import DeviceNotFound import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.button import DOMAIN as BUTTON @@ -15,6 +16,7 @@ from homeassistant.components.update import DOMAIN as UPDATE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import async_get_platforms from . import configure_integration @@ -24,16 +26,22 @@ from .mock import MockDevice from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_device") -async def test_setup_entry(hass: HomeAssistant) -> None: +async def test_setup_entry( + hass: HomeAssistant, + mock_device: MockDevice, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test setup entry.""" entry = configure_integration(hass) - with patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", - return_value=True, - ), patch("homeassistant.core.EventBus.async_listen_once"): - assert await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + device_info = device_registry.async_get_device( + {(DOMAIN, mock_device.serial_number)} + ) + assert device_info == snapshot @pytest.mark.usefixtures("mock_device") diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index cb6de649e8e..d80e9133a0a 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -3,22 +3,18 @@ from devolo_plc_api.device_api import UPDATE_NOT_AVAILABLE, UpdateFirmwareCheck from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import ( DOMAIN, FIRMWARE_UPDATE_INTERVAL, ) -from homeassistant.components.update import ( - DOMAIN as PLATFORM, - SERVICE_INSTALL, - UpdateDeviceClass, -) +from homeassistant.components.update import DOMAIN as PLATFORM, SERVICE_INSTALL from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import configure_integration from .const import FIRMWARE_UPDATE_AVAILABLE @@ -43,8 +39,10 @@ async def test_update_setup(hass: HomeAssistant) -> None: async def test_update_firmware( hass: HomeAssistant, mock_device: MockDevice, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, ) -> None: """Test updating a device.""" entry = configure_integration(hass) @@ -54,17 +52,8 @@ async def test_update_firmware( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_ON - assert state.attributes["device_class"] == UpdateDeviceClass.FIRMWARE - assert state.attributes["installed_version"] == mock_device.firmware_version - assert ( - state.attributes["latest_version"] - == FIRMWARE_UPDATE_AVAILABLE.new_firmware_version.split("_")[0] - ) - - assert entity_registry.async_get(state_key).entity_category == EntityCategory.CONFIG + assert hass.states.get(state_key) == snapshot + assert entity_registry.async_get(state_key) == snapshot await hass.services.async_call( PLATFORM, @@ -75,6 +64,9 @@ async def test_update_firmware( assert mock_device.device.async_start_firmware_update.call_count == 1 # Emulate state change + mock_device.firmware_version = FIRMWARE_UPDATE_AVAILABLE.new_firmware_version.split( + "_" + )[0] mock_device.device.async_check_firmware_available.return_value = ( UpdateFirmwareCheck(result=UPDATE_NOT_AVAILABLE) ) @@ -86,6 +78,12 @@ async def test_update_firmware( assert state is not None assert state.state == STATE_OFF + device_info = device_registry.async_get_device( + {(DOMAIN, mock_device.serial_number)} + ) + assert device_info is not None + assert device_info.sw_version == mock_device.firmware_version + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 8c94c756edc..67e8b724a97 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -68,9 +68,15 @@ async def dsmr_connection_send_validate_fixture(hass): protocol = MagicMock(spec=DSMRProtocol) protocol.telegram = { - EQUIPMENT_IDENTIFIER: CosemObject([{"value": "12345678", "unit": ""}]), - EQUIPMENT_IDENTIFIER_GAS: CosemObject([{"value": "123456789", "unit": ""}]), - P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), + EQUIPMENT_IDENTIFIER: CosemObject( + EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] + ), + EQUIPMENT_IDENTIFIER_GAS: CosemObject( + EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] + ), + P1_MESSAGE_TIMESTAMP: CosemObject( + P1_MESSAGE_TIMESTAMP, [{"value": "12345678", "unit": ""}] + ), } async def connection_factory(*args, **kwargs): @@ -78,20 +84,22 @@ async def dsmr_connection_send_validate_fixture(hass): if args[1] == "5L": protocol.telegram = { LUXEMBOURG_EQUIPMENT_IDENTIFIER: CosemObject( - [{"value": "12345678", "unit": ""}] + LUXEMBOURG_EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] ), EQUIPMENT_IDENTIFIER_GAS: CosemObject( - [{"value": "123456789", "unit": ""}] + EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] ), } if args[1] == "5S": protocol.telegram = { - P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), + P1_MESSAGE_TIMESTAMP: CosemObject( + P1_MESSAGE_TIMESTAMP, [{"value": "12345678", "unit": ""}] + ), } if args[1] == "Q3D": protocol.telegram = { Q3D_EQUIPMENT_IDENTIFIER: CosemObject( - [{"value": "12345678", "unit": ""}] + Q3D_EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] ), } @@ -129,9 +137,15 @@ async def rfxtrx_dsmr_connection_send_validate_fixture(hass): protocol = MagicMock(spec=RFXtrxDSMRProtocol) protocol.telegram = { - EQUIPMENT_IDENTIFIER: CosemObject([{"value": "12345678", "unit": ""}]), - EQUIPMENT_IDENTIFIER_GAS: CosemObject([{"value": "123456789", "unit": ""}]), - P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), + EQUIPMENT_IDENTIFIER: CosemObject( + EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] + ), + EQUIPMENT_IDENTIFIER_GAS: CosemObject( + EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] + ), + P1_MESSAGE_TIMESTAMP: CosemObject( + P1_MESSAGE_TIMESTAMP, [{"value": "12345678", "unit": ""}] + ), } async def connection_factory(*args, **kwargs): diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index ed04bda02f8..9c8c4e6fc70 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -11,6 +11,7 @@ from itertools import chain, repeat from unittest.mock import DEFAULT, MagicMock from homeassistant import config_entries +from homeassistant.components.dsmr.const import BELGIUM_5MIN_GAS_METER_READING from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -22,8 +23,6 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -59,14 +58,18 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No telegram = { CURRENT_ELECTRICITY_USAGE: CosemObject( - [{"value": Decimal("0.0"), "unit": UnitOfPower.WATT}] + CURRENT_ELECTRICITY_USAGE, + [{"value": Decimal("0.0"), "unit": UnitOfPower.WATT}], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), - ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), GAS_METER_READING: MBusObject( + GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": UnitOfVolume.CUBIC_METERS}, - ] + ], ), } @@ -79,6 +82,14 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + registry = er.async_get(hass) entry = registry.async_get("sensor.electricity_meter_power_consumption") @@ -89,11 +100,9 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No assert entry assert entry.unique_id == "5678_gas_meter_reading" - telegram_callback = connection_factory.call_args_list[0][0][2] - - # make sure entities have been created and return 'unavailable' state + # make sure entities are initialized power_consumption = hass.states.get("sensor.electricity_meter_power_consumption") - assert power_consumption.state == STATE_UNAVAILABLE + assert power_consumption.state == "0.0" assert ( power_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER ) @@ -102,7 +111,24 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No power_consumption.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT ) - assert power_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert power_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "W" + + telegram = { + CURRENT_ELECTRICITY_USAGE: CosemObject( + CURRENT_ELECTRICITY_USAGE, + [{"value": Decimal("35.0"), "unit": UnitOfPower.WATT}], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] + ), + GAS_METER_READING: MBusObject( + GAS_METER_READING, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(745.701), "unit": UnitOfVolume.CUBIC_METERS}, + ], + ), + } # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) @@ -112,7 +138,7 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No # ensure entities have new state value after incoming telegram power_consumption = hass.states.get("sensor.electricity_meter_power_consumption") - assert power_consumption.state == "0.0" + assert power_consumption.state == "35.0" assert power_consumption.attributes.get("unit_of_measurement") == UnitOfPower.WATT # tariff should be translated in human readable and have no unit @@ -126,11 +152,11 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No ) assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == "745.695" + assert gas_consumption.state == "745.701" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_FRIENDLY_NAME) @@ -148,6 +174,14 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test the default setup.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + CURRENT_ELECTRICITY_USAGE, + ELECTRICITY_ACTIVE_TARIFF, + ) + from dsmr_parser.objects import CosemObject + entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", @@ -155,9 +189,22 @@ async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) - "reconnect_interval": 30, "serial_id": "1234", } + entry_options = { + "time_between_update": 0, + } + + telegram = { + CURRENT_ELECTRICITY_USAGE: CosemObject( + CURRENT_ELECTRICITY_USAGE, + [{"value": Decimal("35.0"), "unit": UnitOfPower.WATT}], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] + ), + } mock_entry = MockConfigEntry( - domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options ) mock_entry.add_to_hass(hass) @@ -165,6 +212,14 @@ async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) - await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + registry = er.async_get(hass) entry = registry.async_get("sensor.electricity_meter_power_consumption") @@ -199,12 +254,15 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: telegram = { HOURLY_GAS_METER_READING: MBusObject( + HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, - ] + ], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), - ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), } mock_entry = MockConfigEntry( @@ -221,8 +279,8 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # tariff should be translated in human readable and have no unit active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") @@ -231,7 +289,7 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") @@ -275,12 +333,15 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: telegram = { HOURLY_GAS_METER_READING: MBusObject( + HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, - ] + ], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), - ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), } mock_entry = MockConfigEntry( @@ -297,8 +358,8 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # tariff should be translated in human readable and have no unit active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") @@ -307,7 +368,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") @@ -348,16 +409,19 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> telegram = { HOURLY_GAS_METER_READING: MBusObject( + HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, - ] + ], ), ELECTRICITY_IMPORTED_TOTAL: CosemObject( - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_IMPORTED_TOTAL, + [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), ELECTRICITY_EXPORTED_TOTAL: CosemObject( - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_EXPORTED_TOTAL, + [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), } @@ -375,8 +439,8 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") assert active_tariff.state == "123.456" @@ -417,7 +481,8 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No (connection_factory, transport, protocol) = dsmr_connection_fixture from dsmr_parser.obis_references import ( - BELGIUM_5MIN_GAS_METER_READING, + BELGIUM_CURRENT_AVERAGE_DEMAND, + BELGIUM_MAXIMUM_DEMAND_MONTH, ELECTRICITY_ACTIVE_TARIFF, ) from dsmr_parser.objects import CosemObject, MBusObject @@ -436,12 +501,26 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No telegram = { BELGIUM_5MIN_GAS_METER_READING: MBusObject( + BELGIUM_5MIN_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, - ] + ], + ), + BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( + BELGIUM_CURRENT_AVERAGE_DEMAND, + [{"value": Decimal(1.75), "unit": "kW"}], + ), + BELGIUM_MAXIMUM_DEMAND_MONTH: MBusObject( + BELGIUM_MAXIMUM_DEMAND_MONTH, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(4.11), "unit": "kW"}, + ], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), - ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), } mock_entry = MockConfigEntry( @@ -458,8 +537,8 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # tariff should be translated in human readable and have no unit active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") @@ -468,7 +547,21 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + + # check current average demand is parsed correctly + avg_demand = hass.states.get("sensor.electricity_meter_current_average_demand") + assert avg_demand.state == "1.75" + assert avg_demand.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.KILO_WATT + assert avg_demand.attributes.get(ATTR_STATE_CLASS) is None + + # check max average demand is parsed correctly + max_demand = hass.states.get( + "sensor.electricity_meter_maximum_demand_current_month" + ) + assert max_demand.state == "4.11" + assert max_demand.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.KILO_WATT + assert max_demand.attributes.get(ATTR_STATE_CLASS) is None # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") @@ -503,7 +596,11 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - "time_between_update": 0, } - telegram = {ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0002", "unit": ""}])} + telegram = { + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0002", "unit": ""}] + ) + } mock_entry = MockConfigEntry( domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options @@ -519,8 +616,8 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # tariff should be translated in human readable and have no unit active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") @@ -529,7 +626,7 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: @@ -556,10 +653,12 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No telegram = { ELECTRICITY_IMPORTED_TOTAL: CosemObject( - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_IMPORTED_TOTAL, + [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), ELECTRICITY_EXPORTED_TOTAL: CosemObject( - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_EXPORTED_TOTAL, + [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), } @@ -577,8 +676,8 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") assert active_tariff.state == "123.456" @@ -629,10 +728,12 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: telegram = { ELECTRICITY_IMPORTED_TOTAL: CosemObject( - [{"value": Decimal(54184.6316), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_IMPORTED_TOTAL, + [{"value": Decimal(54184.6316), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), ELECTRICITY_EXPORTED_TOTAL: CosemObject( - [{"value": Decimal(19981.1069), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_EXPORTED_TOTAL, + [{"value": Decimal(19981.1069), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), } @@ -653,8 +754,8 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") assert active_tariff.state == "54184.6316" @@ -778,6 +879,12 @@ async def test_connection_errors_retry( async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: """If transport disconnects, the connection should be retried.""" + from dsmr_parser.obis_references import ( + CURRENT_ELECTRICITY_USAGE, + ELECTRICITY_ACTIVE_TARIFF, + ) + from dsmr_parser.objects import CosemObject + (connection_factory, transport, protocol) = dsmr_connection_fixture entry_data = { @@ -788,6 +895,19 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: "serial_id": "1234", "serial_id_gas": "5678", } + entry_options = { + "time_between_update": 0, + } + + telegram = { + CURRENT_ELECTRICITY_USAGE: CosemObject( + CURRENT_ELECTRICITY_USAGE, + [{"value": Decimal("35.0"), "unit": UnitOfPower.WATT}], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] + ), + } # mock waiting coroutine while connection lasts closed = asyncio.Event() @@ -801,7 +921,7 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: protocol.wait_closed = wait_closed mock_entry = MockConfigEntry( - domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options ) mock_entry.add_to_hass(hass) @@ -809,11 +929,19 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + assert connection_factory.call_count == 1 state = hass.states.get("sensor.electricity_meter_power_consumption") assert state - assert state.state == STATE_UNKNOWN + assert state.state == "35.0" # indicate disconnect, release wait lock and allow reconnect to happen closed.set() @@ -856,10 +984,11 @@ async def test_gas_meter_providing_energy_reading( telegram = { GAS_METER_READING: MBusObject( + GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(123.456), "unit": UnitOfEnergy.GIGA_JOULE}, - ] + ], ), } @@ -874,7 +1003,7 @@ async def test_gas_meter_providing_energy_reading( telegram_callback = connection_factory.call_args_list[0][0][2] telegram_callback(telegram) - await asyncio.sleep(0) + await hass.async_block_till_done() gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption.state == "123.456" diff --git a/tests/components/duotecno/test_config_flow.py b/tests/components/duotecno/test_config_flow.py index a2dc265ae6e..a02fea8008c 100644 --- a/tests/components/duotecno/test_config_flow.py +++ b/tests/components/duotecno/test_config_flow.py @@ -9,6 +9,8 @@ from homeassistant.components.duotecno.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -87,3 +89,20 @@ async def test_invalid(hass: HomeAssistant, test_side_effect, test_error): "port": 1234, "password": "test-password2", } + + +async def test_already_setup(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test duoteco flow - already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="duotecno_1234", + data={}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/eight_sleep/conftest.py b/tests/components/eight_sleep/conftest.py deleted file mode 100644 index 753fe1e30d5..00000000000 --- a/tests/components/eight_sleep/conftest.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Fixtures for Eight Sleep.""" -from unittest.mock import patch - -from pyeight.exceptions import RequestError -import pytest - - -@pytest.fixture(name="bypass", autouse=True) -def bypass_fixture(): - """Bypasses things that slow te tests down or block them from testing the behavior.""" - with patch( - "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", - ), patch( - "homeassistant.components.eight_sleep.config_flow.EightSleep.at_exit", - ), patch( - "homeassistant.components.eight_sleep.async_setup_entry", - return_value=True, - ): - yield - - -@pytest.fixture(name="token_error") -def token_error_fixture(): - """Simulate error when fetching token.""" - with patch( - "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", - side_effect=RequestError, - ): - yield diff --git a/tests/components/eight_sleep/test_config_flow.py b/tests/components/eight_sleep/test_config_flow.py deleted file mode 100644 index 6a64f6a5731..00000000000 --- a/tests/components/eight_sleep/test_config_flow.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Test the Eight Sleep config flow.""" -from homeassistant import config_entries -from homeassistant.components.eight_sleep.const import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - } - - -async def test_form_invalid_auth(hass: HomeAssistant, token_error) -> None: - """Test we handle invalid auth.""" - 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 - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "bad-username", - "password": "bad-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_import(hass: HomeAssistant) -> None: - """Test import works.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": "test-username", - "password": "test-password", - }, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - - -async def test_import_invalid_auth(hass: HomeAssistant, token_error) -> None: - """Test we handle invalid auth on import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": "bad-username", - "password": "bad-password", - }, - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" diff --git a/tests/components/eight_sleep/test_init.py b/tests/components/eight_sleep/test_init.py new file mode 100644 index 00000000000..6b94ff31139 --- /dev/null +++ b/tests/components/eight_sleep/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the Eight Sleep integration.""" + +from homeassistant.components.eight_sleep import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_mazda_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Eight Sleep configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 920aa40bfe7..e145c0b82bc 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': 'Identify', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'GW24L1A02987_identify', @@ -68,6 +69,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -110,6 +112,7 @@ 'original_icon': None, 'original_name': 'Restart', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'GW24L1A02987_restart', @@ -142,6 +145,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 31f5dfba217..727170128d1 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -68,6 +68,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CN11A1A00001', @@ -100,6 +101,7 @@ 'model': 'Elgato Key Light', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'CN11A1A00001', 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -176,6 +178,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CN11A1A00001', @@ -208,6 +211,7 @@ 'model': 'Elgato Key Light', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'CN11A1A00001', 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -218,6 +222,8 @@ 'attributes': ReadOnlyDict({ 'brightness': 128, 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Frenck', 'hs_color': tuple( 358.0, @@ -282,6 +288,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CN11A1A00001', @@ -314,6 +321,7 @@ 'model': 'Elgato Key Light', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'CN11A1A00001', 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index 5fa7a6e827a..0322993ef99 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -43,6 +43,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'GW24L1A02987_battery', @@ -75,6 +76,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -127,6 +129,7 @@ 'original_icon': None, 'original_name': 'Battery voltage', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage', 'unique_id': 'GW24L1A02987_voltage', @@ -159,6 +162,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -211,6 +215,7 @@ 'original_icon': None, 'original_name': 'Charging current', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'input_charge_current', 'unique_id': 'GW24L1A02987_input_charge_current', @@ -243,6 +248,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -292,6 +298,7 @@ 'original_icon': None, 'original_name': 'Charging power', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_power', 'unique_id': 'GW24L1A02987_charge_power', @@ -324,6 +331,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -376,6 +384,7 @@ 'original_icon': None, 'original_name': 'Charging voltage', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'input_charge_voltage', 'unique_id': 'GW24L1A02987_input_charge_voltage', @@ -408,6 +417,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index dcba00c0a9e..d6b8896d5a2 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -36,6 +36,7 @@ 'original_icon': 'mdi:leaf', 'original_name': 'Energy saving', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saving', 'unique_id': 'GW24L1A02987_energy_saving', @@ -68,6 +69,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -110,6 +112,7 @@ 'original_icon': 'mdi:battery-off-outline', 'original_name': 'Studio mode', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'bypass', 'unique_id': 'GW24L1A02987_bypass', @@ -142,6 +145,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 24acde0709a..fb5ff265497 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -36,9 +36,11 @@ from homeassistant.components.emulated_hue.hue_api import ( HueAllLightsStateView, HueConfigView, HueFullStateView, + HueGroupView, HueOneLightChangeView, HueOneLightStateView, HueUsernameView, + _remote_is_allowed, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -232,6 +234,7 @@ def _mock_hue_endpoints( HueOneLightStateView(config).register(hass, web_app, web_app.router) HueOneLightChangeView(config).register(hass, web_app, web_app.router) HueAllGroupsStateView(config).register(hass, web_app, web_app.router) + HueGroupView(config).register(hass, web_app, web_app.router) HueFullStateView(config).register(hass, web_app, web_app.router) HueConfigView(config).register(hass, web_app, web_app.router) @@ -1327,23 +1330,33 @@ async def test_external_ip_blocked(hue_client) -> None: "/api/username/lights/light.ceiling_lights", ] postUrls = ["/api"] - putUrls = ["/api/username/lights/light.ceiling_lights/state"] + putUrls = [ + "/api/username/lights/light.ceiling_lights/state", + "/api/username/groups/0/action", + ] with patch( "homeassistant.components.emulated_hue.hue_api.ip_address", return_value=ip_address("45.45.45.45"), ): for getUrl in getUrls: + _remote_is_allowed.cache_clear() result = await hue_client.get(getUrl) assert result.status == HTTPStatus.UNAUTHORIZED for postUrl in postUrls: + _remote_is_allowed.cache_clear() result = await hue_client.post(postUrl) assert result.status == HTTPStatus.UNAUTHORIZED for putUrl in putUrls: + _remote_is_allowed.cache_clear() result = await hue_client.put(putUrl) assert result.status == HTTPStatus.UNAUTHORIZED + # We are patching inside of a cache so be sure to clear it + # so that the next test is not affected + _remote_is_allowed.cache_clear() + async def test_unauthorized_user_blocked(hue_client) -> None: """Test unauthorized_user blocked.""" diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index d045fedd4b3..f953d0e3a03 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -423,6 +423,7 @@ async def test_fossil_energy_consumption_no_co2( response = await client.receive_json() assert response["success"] assert response["result"] == { + period1.isoformat(): pytest.approx(22.0), period2.isoformat(): pytest.approx(33.0 - 22.0), period3.isoformat(): pytest.approx(55.0 - 33.0), period4.isoformat(): pytest.approx(88.0 - 55.0), @@ -445,6 +446,7 @@ async def test_fossil_energy_consumption_no_co2( response = await client.receive_json() assert response["success"] assert response["result"] == { + period1.isoformat(): pytest.approx(22.0), period2_day_start.isoformat(): pytest.approx(33.0 - 22.0), period3.isoformat(): pytest.approx(55.0 - 33.0), period4_day_start.isoformat(): pytest.approx(88.0 - 55.0), @@ -467,7 +469,7 @@ async def test_fossil_energy_consumption_no_co2( response = await client.receive_json() assert response["success"] assert response["result"] == { - period1.isoformat(): pytest.approx(33.0 - 22.0), + period1.isoformat(): pytest.approx(33.0), period3.isoformat(): pytest.approx((55.0 - 33.0) + (88.0 - 55.0)), } @@ -586,8 +588,9 @@ async def test_fossil_energy_consumption_hole( response = await client.receive_json() assert response["success"] assert response["result"] == { - period2.isoformat(): pytest.approx(3.0 - 20.0), - period3.isoformat(): pytest.approx(55.0 - 3.0), + period1.isoformat(): pytest.approx(20.0), + period2.isoformat(): pytest.approx(3.0), + period3.isoformat(): pytest.approx(32.0), period4.isoformat(): pytest.approx(88.0 - 55.0), } @@ -608,8 +611,9 @@ async def test_fossil_energy_consumption_hole( response = await client.receive_json() assert response["success"] assert response["result"] == { - period2_day_start.isoformat(): pytest.approx(3.0 - 20.0), - period3.isoformat(): pytest.approx(55.0 - 3.0), + period1.isoformat(): pytest.approx(20.0), + period2_day_start.isoformat(): pytest.approx(3.0), + period3.isoformat(): pytest.approx(32.0), period4_day_start.isoformat(): pytest.approx(88.0 - 55.0), } @@ -630,8 +634,8 @@ async def test_fossil_energy_consumption_hole( response = await client.receive_json() assert response["success"] assert response["result"] == { - period1.isoformat(): pytest.approx(3.0 - 20.0), - period3.isoformat(): pytest.approx((55.0 - 3.0) + (88.0 - 55.0)), + period1.isoformat(): pytest.approx(23.0), + period3.isoformat(): pytest.approx((55.0 - 3.0) + (88.0 - 55.0) - 20.0), } @@ -930,6 +934,7 @@ async def test_fossil_energy_consumption( response = await client.receive_json() assert response["success"] assert response["result"] == { + period1.isoformat(): pytest.approx(11.0 * 0.2), period2.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), period4.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), @@ -952,6 +957,7 @@ async def test_fossil_energy_consumption( response = await client.receive_json() assert response["success"] assert response["result"] == { + period1.isoformat(): pytest.approx(11.0 * 0.2), period2_day_start.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), period4_day_start.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), @@ -974,7 +980,7 @@ async def test_fossil_energy_consumption( response = await client.receive_json() assert response["success"] assert response["result"] == { - period1.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), + period1.isoformat(): pytest.approx(11.0 * 0.5), period3.isoformat(): pytest.approx( ((44.0 - 33.0) * 0.6) + ((55.0 - 44.0) * 0.9) ), @@ -1032,3 +1038,120 @@ async def test_fossil_energy_consumption_checks( assert msg["id"] == 2 assert not msg["success"] assert msg["error"] == {"code": "invalid_end_time", "message": "Invalid end_time"} + + +@pytest.mark.freeze_time("2021-08-01 01:00:00+00:00") +async def test_fossil_energy_consumption_check_missing_hour( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test explicitly if the API keeps the first hour of data for the requested time frame.""" + + now = dt_util.utcnow() + later = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 05:00:00")) + + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + hour1 = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 01:00:00")) + hour2 = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 02:00:00")) + hour3 = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 03:00:00")) + hour4 = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 04:00:00")) + + # add energy statistics for 4 hours + energy_statistics_1 = ( + { + "start": hour1, + "last_reset": None, + "state": 0, + "sum": 1, + }, + { + "start": hour2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": hour3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": hour4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, energy_metadata_1, energy_statistics_1) + + # add co2 statistics for 4 hours + co2_statistics = ( + { + "start": hour1, + "last_reset": None, + "mean": 10, + }, + { + "start": hour2, + "last_reset": None, + "mean": 30, + }, + { + "start": hour3, + "last_reset": None, + "mean": 60, + }, + { + "start": hour4, + "last_reset": None, + "mean": 90, + }, + ) + co2_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Fossil percentage", + "source": "test", + "statistic_id": "test:fossil_percentage", + "unit_of_measurement": "%", + } + + async_add_external_statistics(hass, co2_metadata, co2_statistics) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "hour", + } + ) + + # check if we received deltas for the requested time frame + response = await client.receive_json() + assert response["success"] + assert list(response["result"].keys()) == [ + hour1.isoformat(), + hour2.isoformat(), + hour3.isoformat(), + hour4.isoformat(), + ] diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index e51aef980d1..00579ec7026 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -494,6 +494,7 @@ 'original_icon': None, 'original_name': 'Average - today', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'average_price', 'unit_of_measurement': '€/kWh', @@ -515,6 +516,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -561,6 +563,7 @@ 'original_icon': None, 'original_name': 'Current hour', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', 'unit_of_measurement': '€/kWh', @@ -582,6 +585,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -625,6 +629,7 @@ 'original_icon': None, 'original_name': 'Time of highest price - today', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'highest_price_time', 'unit_of_measurement': None, @@ -646,6 +651,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -690,6 +696,7 @@ 'original_icon': 'mdi:clock', 'original_name': 'Hours priced equal or lower than current - today', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hours_priced_equal_or_lower', 'unit_of_measurement': , @@ -711,6 +718,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -754,6 +762,7 @@ 'original_icon': None, 'original_name': 'Highest price - today', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'max_price', 'unit_of_measurement': '€/kWh', @@ -775,6 +784,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -821,6 +831,7 @@ 'original_icon': None, 'original_name': 'Current hour', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', 'unit_of_measurement': '€/m³', @@ -842,6 +853,7 @@ 'model': None, 'name': 'Gas market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py new file mode 100644 index 00000000000..64484b91e07 --- /dev/null +++ b/tests/components/esphome/test_entry_data.py @@ -0,0 +1,110 @@ +"""Test ESPHome entry data.""" + +from aioesphomeapi import ( + APIClient, + EntityCategory as ESPHomeEntityCategory, + SensorInfo, + SensorState, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def test_migrate_entity_unique_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic sensor entity unique id migration.""" + ent_reg = er.async_get(hass) + ent_reg.async_get_or_create( + "sensor", + "esphome", + "my_sensor", + suggested_object_id="old_sensor", + disabled_by=None, + ) + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + entity_category=ESPHomeEntityCategory.DIAGNOSTIC, + icon="mdi:leaf", + ) + ] + states = [SensorState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.old_sensor") + assert state is not None + assert state.state == "50" + entity_reg = er.async_get(hass) + entry = entity_reg.async_get("sensor.old_sensor") + assert entry is not None + assert entity_reg.async_get_entity_id("sensor", "esphome", "my_sensor") is None + # Note that ESPHome includes the EntityInfo type in the unique id + # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) + assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + + +async def test_migrate_entity_unique_id_downgrade_upgrade( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test unique id migration prefers the original entity on downgrade upgrade.""" + ent_reg = er.async_get(hass) + ent_reg.async_get_or_create( + "sensor", + "esphome", + "my_sensor", + suggested_object_id="old_sensor", + disabled_by=None, + ) + ent_reg.async_get_or_create( + "sensor", + "esphome", + "11:22:33:44:55:aa-sensor-mysensor", + suggested_object_id="new_sensor", + disabled_by=None, + ) + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + entity_category=ESPHomeEntityCategory.DIAGNOSTIC, + icon="mdi:leaf", + ) + ] + states = [SensorState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.new_sensor") + assert state is not None + assert state.state == "50" + entity_reg = er.async_get(hass) + entry = entity_reg.async_get("sensor.new_sensor") + assert entry is not None + # Confirm we did not touch the entity that was created + # on downgrade so when they upgrade again they can delete the + # entity that was only created on downgrade and they keep + # the original one. + assert entity_reg.async_get_entity_id("sensor", "esphome", "my_sensor") is not None + # Note that ESPHome includes the EntityInfo type in the unique id + # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) + assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index e46906ffd33..820ec9ad9c0 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -97,7 +97,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( key=1, name="my sensor", unique_id="my_sensor", - entity_category=ESPHomeEntityCategory.CONFIG, + entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) ] @@ -116,8 +116,10 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( entity_reg = er.async_get(hass) entry = entity_reg.async_get("sensor.test_mysensor") assert entry is not None - assert entry.unique_id == "my_sensor" - assert entry.entity_category is EntityCategory.CONFIG + # Note that ESPHome includes the EntityInfo type in the unique id + # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) + assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + assert entry.entity_category is EntityCategory.DIAGNOSTIC async def test_generic_numeric_sensor_state_class_measurement( @@ -152,7 +154,9 @@ async def test_generic_numeric_sensor_state_class_measurement( entity_reg = er.async_get(hass) entry = entity_reg.async_get("sensor.test_mysensor") assert entry is not None - assert entry.unique_id == "my_sensor" + # Note that ESPHome includes the EntityInfo type in the unique id + # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) + assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" assert entry.entity_category is None diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py new file mode 100644 index 00000000000..07157d98ac6 --- /dev/null +++ b/tests/components/esphome/test_text.py @@ -0,0 +1,115 @@ +"""Test ESPHome texts.""" + +from unittest.mock import call + +from aioesphomeapi import APIClient, TextInfo, TextMode as ESPHomeTextMode, TextState + +from homeassistant.components.text import ( + ATTR_VALUE, + DOMAIN as TEXT_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + + +async def test_generic_text_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [TextState(key=1, state="hello world")] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == "hello world" + + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "text.test_mytext", ATTR_VALUE: "goodbye"}, + blocking=True, + ) + mock_client.text_command.assert_has_calls([call(1, "goodbye")]) + mock_client.text_command.reset_mock() + + +async def test_generic_text_entity_no_state( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity that has no state.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_generic_text_entity_missing_state( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity that has no state.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [TextState(key=1, state="", missing_state=True)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 5404c80340e..b8756d9ace5 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -103,9 +103,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -119,7 +131,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -131,7 +143,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -143,7 +155,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -159,6 +171,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - hass.bus.async_fire("test_event_turn_off") await hass.async_block_till_done() assert len(turn_off_calls) == 1 + assert turn_off_calls[0].data["entity_id"] == entry.entity_id assert len(turn_on_calls) == 0 assert len(toggle_calls) == 0 @@ -166,6 +179,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - await hass.async_block_till_done() assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 + assert turn_on_calls[0].data["entity_id"] == entry.entity_id assert len(toggle_calls) == 0 hass.bus.async_fire("test_event_toggle") @@ -173,13 +187,24 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 assert len(toggle_calls) == 1 + assert toggle_calls[0].data["entity_id"] == entry.entity_id async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -193,7 +218,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index 20c84eb1436..1ee168f28ab 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -110,10 +110,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -128,7 +139,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -150,7 +161,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -184,10 +195,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -202,7 +224,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index f1de07a9e97..8ac5e79ba5b 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -176,10 +176,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -192,7 +203,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -214,7 +225,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -236,7 +247,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -278,10 +289,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -294,7 +316,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -327,10 +349,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -343,7 +376,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 2b6580c3191..d86817814b1 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -2,16 +2,22 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch -from pyfibaro.fibaro_scene import SceneModel import pytest -from homeassistant.components.fibaro import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.components.fibaro import CONF_IMPORT_PLUGINS, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +TEST_SERIALNUMBER = "HC2-111111" +TEST_NAME = "my_fibaro_home_center" +TEST_URL = "http://192.168.1.1/api/" +TEST_USERNAME = "user" +TEST_PASSWORD = "password" +TEST_VERSION = "4.360" +TEST_MODEL = "HC3" + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -22,10 +28,10 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup_entry -@pytest.fixture(name="fibaro_scene") -def mock_scene() -> SceneModel: +@pytest.fixture +def mock_scene() -> Mock: """Fixture for an individual scene.""" - scene = Mock(SceneModel) + scene = Mock() scene.fibaro_id = 1 scene.name = "Test scene" scene.room_id = 1 @@ -33,23 +39,58 @@ def mock_scene() -> SceneModel: return scene -async def setup_platform( - hass: HomeAssistant, - platform: Platform, - room_name: str | None, - scenes: list[SceneModel], -) -> ConfigEntry: - """Set up the fibaro platform and prerequisites.""" - hass.config.components.add(DOMAIN) - config_entry = MockConfigEntry(domain=DOMAIN, title="Test") - config_entry.add_to_hass(hass) +@pytest.fixture +def mock_room() -> Mock: + """Fixture for an individual room.""" + room = Mock() + room.fibaro_id = 1 + room.name = "Room 1" + return room - controller_mock = Mock() - controller_mock.hub_serial = "HC2-111111" - controller_mock.get_room_name.return_value = room_name - controller_mock.read_scenes.return_value = scenes - hass.data[DOMAIN] = {config_entry.entry_id: controller_mock} - await hass.config_entries.async_forward_entry_setup(config_entry, platform) +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_IMPORT_PLUGINS: True, + }, + ) + mock_config_entry.add_to_hass(hass) + return mock_config_entry + + +@pytest.fixture +def mock_fibaro_client() -> Generator[Mock, None, None]: + """Return a mocked FibaroClient.""" + info_mock = Mock() + info_mock.serial_number = TEST_SERIALNUMBER + info_mock.hc_name = TEST_NAME + info_mock.current_version = TEST_VERSION + info_mock.platform = TEST_MODEL + + with patch( + "homeassistant.components.fibaro.FibaroClient", autospec=True + ) as fibaro_client_mock: + client = fibaro_client_mock.return_value + client.set_authentication.return_value = None + client.connect.return_value = True + client.read_info.return_value = info_mock + client.read_rooms.return_value = [] + client.read_scenes.return_value = [] + client.read_devices.return_value = [] + client.register_update_handler.return_value = None + client.unregister_update_handler.return_value = None + yield client + + +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the fibaro integration for testing.""" + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - return config_entry diff --git a/tests/components/fibaro/test_scene.py b/tests/components/fibaro/test_scene.py index 09e0543976f..0ce618e903c 100644 --- a/tests/components/fibaro/test_scene.py +++ b/tests/components/fibaro/test_scene.py @@ -1,21 +1,30 @@ """Test the Fibaro scene platform.""" - -from pyfibaro.fibaro_scene import SceneModel +from unittest.mock import Mock from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from .conftest import init_integration + +from tests.common import MockConfigEntry -async def test_entity_attributes(hass: HomeAssistant, fibaro_scene: SceneModel) -> None: +async def test_entity_attributes( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_scene: Mock, + mock_room: Mock, +) -> None: """Test that the attributes of the entity are correct.""" # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_scenes.return_value = [mock_scene] entity_registry = er.async_get(hass) # Act - await setup_platform(hass, Platform.SCENE, "Room 1", [fibaro_scene]) + await init_integration(hass, mock_config_entry) # Assert entry = entity_registry.async_get("scene.room_1_test_scene") @@ -25,13 +34,20 @@ async def test_entity_attributes(hass: HomeAssistant, fibaro_scene: SceneModel) async def test_entity_attributes_without_room( - hass: HomeAssistant, fibaro_scene: SceneModel + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_scene: Mock, + mock_room: Mock, ) -> None: """Test that the attributes of the entity are correct.""" # Arrange + mock_room.name = None + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_scenes.return_value = [mock_scene] entity_registry = er.async_get(hass) # Act - await setup_platform(hass, Platform.SCENE, None, [fibaro_scene]) + await init_integration(hass, mock_config_entry) # Assert entry = entity_registry.async_get("scene.unknown_test_scene") @@ -39,10 +55,19 @@ async def test_entity_attributes_without_room( assert entry.unique_id == "hc2_111111.scene.1" -async def test_activate_scene(hass: HomeAssistant, fibaro_scene: SceneModel) -> None: +async def test_activate_scene( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_scene: Mock, + mock_room: Mock, +) -> None: """Test activate scene is called.""" # Arrange - await setup_platform(hass, Platform.SCENE, "Room 1", [fibaro_scene]) + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_scenes.return_value = [mock_scene] + # Act + await init_integration(hass, mock_config_entry) # Act await hass.services.async_call( SCENE_DOMAIN, @@ -51,4 +76,4 @@ async def test_activate_scene(hass: HomeAssistant, fibaro_scene: SceneModel) -> blocking=True, ) # Assert - assert fibaro_scene.start.call_count == 1 + assert mock_scene.start.call_count == 1 diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 7499a060933..682fb0edd3b 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -10,15 +10,28 @@ from unittest.mock import patch import pytest from requests_mock.mocker import Mocker -from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.fitbit.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + DOMAIN, + OAUTH_SCOPES, +) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + CLIENT_ID = "1234" CLIENT_SECRET = "5678" PROFILE_USER_ID = "fitbit-api-user-id-1" -FAKE_TOKEN = "some-token" +FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" +FAKE_AUTH_IMPL = "conftest-imported-cred" PROFILE_API_URL = "https://api.fitbit.com/1/user/-/profile.json" DEVICES_API_URL = "https://api.fitbit.com/1/user/-/devices.json" @@ -26,6 +39,15 @@ TIMESERIES_API_URL_FORMAT = ( "https://api.fitbit.com/1/user/-/{resource}/date/today/7d.json" ) +# These constants differ from values in the config entry or fitbit.conf +SERVER_ACCESS_TOKEN = { + "refresh_token": "server-refresh-token", + "access_token": "server-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), +} + @pytest.fixture(name="token_expiration_time") def mcok_token_expiration_time() -> float: @@ -33,29 +55,73 @@ def mcok_token_expiration_time() -> float: return time.time() + 86400 +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture for expiration time of the config entry auth token.""" + return OAUTH_SCOPES + + +@pytest.fixture(name="token_entry") +def mock_token_entry(token_expiration_time: float, scopes: list[str]) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(scopes), + "token_type": "Bearer", + "expires_at": token_expiration_time, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": FAKE_AUTH_IMPL, + "token": token_entry, + }, + unique_id=PROFILE_USER_ID, + ) + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + FAKE_AUTH_IMPL, + ) + + @pytest.fixture(name="fitbit_config_yaml") -def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any]: +def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any] | None: """Fixture for the yaml fitbit.conf file contents.""" return { - "access_token": FAKE_TOKEN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "access_token": FAKE_ACCESS_TOKEN, "refresh_token": FAKE_REFRESH_TOKEN, "last_saved_at": token_expiration_time, } -@pytest.fixture(name="fitbit_config_setup", autouse=True) +@pytest.fixture(name="fitbit_config_setup") def mock_fitbit_config_setup( - fitbit_config_yaml: dict[str, Any], + fitbit_config_yaml: dict[str, Any] | None, ) -> Generator[None, None, None]: """Fixture to mock out fitbit.conf file data loading and persistence.""" - + has_config = fitbit_config_yaml is not None with patch( - "homeassistant.components.fitbit.sensor.os.path.isfile", return_value=True + "homeassistant.components.fitbit.sensor.os.path.isfile", + return_value=has_config, ), patch( "homeassistant.components.fitbit.sensor.load_json_object", return_value=fitbit_config_yaml, - ), patch( - "homeassistant.components.fitbit.sensor.save_json", ): yield @@ -112,6 +178,30 @@ async def mock_sensor_platform_setup( return run +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platforms: list[str], +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + @pytest.fixture(name="profile_id") def mock_profile_id() -> str: """Fixture for the profile id returned from the API response.""" diff --git a/tests/components/fitbit/snapshots/test_sensor.ambr b/tests/components/fitbit/snapshots/test_sensor.ambr index 719a2f8a6b8..55b2639a56d 100644 --- a/tests/components/fitbit/snapshots/test_sensor.ambr +++ b/tests/components/fitbit/snapshots/test_sensor.ambr @@ -1,4 +1,52 @@ # serializer version: 1 +# name: test_nutrition_scope_config_entry[scopes0-unit_system0] + tuple( + '99', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Water', + 'icon': 'mdi:cup-water', + 'state_class': , + 'unit_of_measurement': , + }), + ) +# --- +# name: test_nutrition_scope_config_entry[scopes0-unit_system0].1 + tuple( + '1600', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Calories In', + 'icon': 'mdi:food-apple', + 'state_class': , + 'unit_of_measurement': 'cal', + }), + ) +# --- +# name: test_nutrition_scope_config_entry[scopes1-unit_system1] + tuple( + '99', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Water', + 'icon': 'mdi:cup-water', + 'state_class': , + 'unit_of_measurement': , + }), + ) +# --- +# name: test_nutrition_scope_config_entry[scopes1-unit_system1].1 + tuple( + '1600', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Calories In', + 'icon': 'mdi:food-apple', + 'state_class': , + 'unit_of_measurement': 'cal', + }), + ) +# --- # name: test_sensors[monitored_resources0-sensor.activity_calories-activities/activityCalories-135] tuple( '135', @@ -6,6 +54,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Activity Calories', 'icon': 'mdi:fire', + 'state_class': , 'unit_of_measurement': 'cal', }), 'fitbit-api-user-id-1_activities/activityCalories', @@ -18,6 +67,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Calories', 'icon': 'mdi:fire', + 'state_class': , 'unit_of_measurement': 'cal', }), 'fitbit-api-user-id-1_activities/calories', @@ -30,6 +80,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Steps', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': 'steps', }), 'fitbit-api-user-id-1_activities/steps', @@ -82,6 +133,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Awakenings Count', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': 'times awaken', }), 'fitbit-api-user-id-1_sleep/awakeningsCount', @@ -108,6 +160,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes After Wakeup', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/minutesAfterWakeup', @@ -121,6 +174,7 @@ 'device_class': 'duration', 'friendly_name': 'Sleep Minutes Asleep', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/minutesAsleep', @@ -134,6 +188,7 @@ 'device_class': 'duration', 'friendly_name': 'Sleep Minutes Awake', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/minutesAwake', @@ -147,6 +202,7 @@ 'device_class': 'duration', 'friendly_name': 'Sleep Minutes to Fall Asleep', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/minutesToFallAsleep', @@ -160,6 +216,7 @@ 'device_class': 'distance', 'friendly_name': 'Distance', 'icon': 'mdi:map-marker', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/distance', @@ -184,6 +241,7 @@ 'device_class': 'duration', 'friendly_name': 'Sleep Time in Bed', 'icon': 'mdi:hotel', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/timeInBed', @@ -197,6 +255,7 @@ 'device_class': 'distance', 'friendly_name': 'Elevation', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/elevation', @@ -209,6 +268,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Floors', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': 'floors', }), 'fitbit-api-user-id-1_activities/floors', @@ -221,6 +281,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Resting Heart Rate', 'icon': 'mdi:heart-pulse', + 'state_class': , 'unit_of_measurement': 'bpm', }), 'fitbit-api-user-id-1_activities/heart', @@ -234,6 +295,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes Fairly Active', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/minutesFairlyActive', @@ -247,6 +309,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes Lightly Active', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/minutesLightlyActive', @@ -260,6 +323,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes Sedentary', 'icon': 'mdi:seat-recline-normal', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/minutesSedentary', @@ -273,6 +337,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes Very Active', 'icon': 'mdi:run', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/minutesVeryActive', diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py new file mode 100644 index 00000000000..d51379c9adc --- /dev/null +++ b/tests/components/fitbit/test_config_flow.py @@ -0,0 +1,600 @@ +"""Test the fitbit config flow.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +import time +from typing import Any +from unittest.mock import patch + +import pytest +from requests_mock.mocker import Mocker + +from homeassistant import config_entries +from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir + +from .conftest import ( + CLIENT_ID, + FAKE_AUTH_IMPL, + PROFILE_API_URL, + PROFILE_USER_ID, + SERVER_ACCESS_TOKEN, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +REDIRECT_URL = "https://example.com/auth/external/callback" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + config_entry = entries[0] + assert config_entry.title == "My name" + assert config_entry.unique_id == PROFILE_USER_ID + + data = dict(config_entry.data) + assert "token" in data + del data["token"]["expires_at"] + assert dict(config_entry.data) == { + "auth_implementation": FAKE_AUTH_IMPL, + "token": SERVER_ACCESS_TOKEN, + } + + +@pytest.mark.parametrize( + ("status_code", "error_reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_token_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, + status_code: HTTPStatus, + error_reason: str, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=status_code, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == error_reason + + +@pytest.mark.parametrize( + ("http_status", "json", "error_reason"), + [ + (HTTPStatus.INTERNAL_SERVER_ERROR, None, "cannot_connect"), + (HTTPStatus.FORBIDDEN, None, "cannot_connect"), + ( + HTTPStatus.UNAUTHORIZED, + { + "errors": [{"errorType": "invalid_grant"}], + }, + "invalid_access_token", + ), + ], +) +async def test_api_failure( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + requests_mock: Mocker, + setup_credentials: None, + http_status: HTTPStatus, + json: Any, + error_reason: str, +) -> None: + """Test a failure to fetch the profile during the setup flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + requests_mock.register_uri( + "GET", + PROFILE_API_URL, + status_code=http_status, + json=json, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == error_reason + + +async def test_config_entry_already_exists( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + requests_mock: Mocker, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, +) -> None: + """Test that an account may only be configured once.""" + + # Verify existing config entry + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + "token_expiration_time", + [time.time() + 86400, time.time() - 86400], + ids=("token_active", "token_expired"), +) +async def test_import_fitbit_config( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, +) -> None: + """Test that platform configuration is imported successfully.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Verify valid profile can be fetched from the API + config_entry = entries[0] + assert config_entry.title == "My name" + assert config_entry.unique_id == PROFILE_USER_ID + + data = dict(config_entry.data) + # Verify imported values from fitbit.conf and configuration.yaml and + # that the token is updated. + assert "token" in data + expires_at = data["token"]["expires_at"] + assert expires_at > time.time() + del data["token"]["expires_at"] + assert dict(config_entry.data) == { + "auth_implementation": DOMAIN, + "clock_format": "24H", + "monitored_resources": ["activities/steps"], + "token": { + "access_token": "server-access-token", + "refresh_token": "server-refresh-token", + "scope": "activity heartrate nutrition profile settings sleep weight", + }, + "unit_system": "default", + } + + # Verify an issue is raised for deprecated configuration.yaml + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import" + + +async def test_import_fitbit_config_failure_cannot_connect( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, +) -> None: + """Test platform configuration fails to import successfully.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + requests_mock.register_uri( + "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 0 + + # Verify an issue is raised that we were unable to import configuration + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + + +@pytest.mark.parametrize( + "status_code", + [ + (HTTPStatus.UNAUTHORIZED), + (HTTPStatus.INTERNAL_SERVER_ERROR), + ], +) +async def test_import_fitbit_config_cannot_refresh( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, + status_code: HTTPStatus, +) -> None: + """Test platform configuration import fails when refreshing the token.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=status_code, + json="", + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 0 + + # Verify an issue is raised that we were unable to import configuration + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + + +async def test_import_fitbit_config_already_exists( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, +) -> None: + """Test that platform configuration is not imported if it already exists.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + + # Verify existing config entry + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_config_entry_setup: + await integration_setup() + + assert len(mock_config_entry_setup.mock_calls) == 1 + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_import_setup: + await sensor_platform_setup() + + assert len(mock_import_setup.mock_calls) == 0 + + # Still one config entry + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Verify an issue is raised for deprecated configuration.yaml + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import" + + +async def test_platform_setup_without_import( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, +) -> None: + """Test platform configuration.yaml but no existing fitbit.conf credentials.""" + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + # Verify no configuration entry is imported since the integration is not + # fully setup properly + assert len(mock_setup.mock_calls) == 0 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 + + # Verify an issue is raised for deprecated configuration.yaml + assert len(issue_registry.issues) == 1 + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_no_import" + + +async def test_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, +) -> None: + """Test OAuth reauthentication flow will update existing config entry.""" + config_entry.add_to_hass(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # config_entry.req initiates reauth + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=none" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": "60", + }, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert config_entry.data["token"]["refresh_token"] == "updated-refresh-token" + + +@pytest.mark.parametrize("profile_id", ["other-user-id"]) +async def test_reauth_wrong_user_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, +) -> None: + """Test OAuth reauthentication where the wrong user is selected.""" + config_entry.add_to_hass(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=none" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "wrong_account" + + assert len(mock_setup.mock_calls) == 0 diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py new file mode 100644 index 00000000000..b6bf75c1c69 --- /dev/null +++ b/tests/components/fitbit/test_init.py @@ -0,0 +1,176 @@ +"""Test fitbit component.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus + +import pytest +from requests_mock.mocker import Mocker + +from homeassistant.components.fitbit.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import ( + CLIENT_ID, + CLIENT_SECRET, + DEVICES_API_URL, + FAKE_ACCESS_TOKEN, + FAKE_REFRESH_TOKEN, + SERVER_ACCESS_TOKEN, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test setting up the integration.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("token_expiration_time", "server_status"), + [ + (12345, HTTPStatus.INTERNAL_SERVER_ERROR), + (12345, HTTPStatus.FORBIDDEN), + (12345, HTTPStatus.NOT_FOUND), + ], +) +async def test_token_refresh_failure( + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + server_status: HTTPStatus, +) -> None: + """Test where token is expired and the refresh attempt fails and will be retried.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=server_status, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("token_expiration_time", [12345]) +async def test_token_refresh_success( + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, +) -> None: + """Test where token is expired and the refresh attempt succeeds.""" + + assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + # Verify token request + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "grant_type": "refresh_token", + "refresh_token": FAKE_REFRESH_TOKEN, + } + + # Verify updated token + assert ( + config_entry.data["token"]["access_token"] + == SERVER_ACCESS_TOKEN["access_token"] + ) + + +@pytest.mark.parametrize("token_expiration_time", [12345]) +async def test_token_requires_reauth( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, +) -> None: + """Test where token is expired and the refresh attempt requires reauth.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=HTTPStatus.UNAUTHORIZED, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_device_update_coordinator_failure( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + requests_mock: Mocker, +) -> None: + """Test case where the device update coordinator fails on the first request.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_device_update_coordinator_reauth( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + requests_mock: Mocker, +) -> None: + """Test case where the device update coordinator fails on the first request.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + status_code=HTTPStatus.UNAUTHORIZED, + json={ + "errors": [{"errorType": "invalid_grant"}], + }, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 636afeacf16..5421a652125 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -2,15 +2,33 @@ from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any import pytest +from requests_mock.mocker import Mocker from syrupy.assertion import SnapshotAssertion +from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_TOKEN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) -from .conftest import PROFILE_USER_ID, timeseries_response +from .conftest import ( + DEVICES_API_URL, + PROFILE_USER_ID, + SERVER_ACCESS_TOKEN, + TIMESERIES_API_URL_FORMAT, + timeseries_response, +) + +from tests.common import MockConfigEntry DEVICE_RESPONSE_CHARGE_2 = { "battery": "Medium", @@ -32,6 +50,24 @@ DEVICE_RESPONSE_ARIA_AIR = { } +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +@pytest.fixture(autouse=True) +def mock_token_refresh(requests_mock: Mocker) -> None: + """Test that platform configuration is imported successfully.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + + @pytest.mark.parametrize( ( "monitored_resources", @@ -176,6 +212,7 @@ DEVICE_RESPONSE_ARIA_AIR = { ) async def test_sensors( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], entity_registry: er.EntityRegistry, @@ -190,6 +227,8 @@ async def test_sensors( api_resource, timeseries_response(api_resource.replace("/", "-"), api_value) ) await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 state = hass.states.get(entity_id) assert state @@ -204,12 +243,15 @@ async def test_sensors( ) async def test_device_battery_level( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], entity_registry: er.EntityRegistry, ) -> None: """Test battery level sensor for devices.""" - await sensor_platform_setup() + assert await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 state = hass.states.get("sensor.charge_2_battery") assert state @@ -269,6 +311,7 @@ async def test_device_battery_level( ) async def test_profile_local( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], expected_unit: str, @@ -277,6 +320,8 @@ async def test_profile_local( register_timeseries("body/weight", timeseries_response("body-weight", "175")) await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 state = hass.states.get("sensor.weight") assert state @@ -315,6 +360,7 @@ async def test_profile_local( ) async def test_sleep_time_clock_format( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], api_response: str, @@ -330,3 +376,398 @@ async def test_sleep_time_clock_format( state = hass.states.get("sensor.sleep_start_time") assert state assert state.state == expected_state + + +@pytest.mark.parametrize( + ("scopes"), + [(["activity"])], +) +async def test_activity_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], +) -> None: + """Test activity sensors are enabled.""" + + for api_resource in ( + "activities/activityCalories", + "activities/calories", + "activities/distance", + "activities/elevation", + "activities/floors", + "activities/minutesFairlyActive", + "activities/minutesLightlyActive", + "activities/minutesSedentary", + "activities/minutesVeryActive", + "activities/steps", + ): + register_timeseries( + api_resource, timeseries_response(api_resource.replace("/", "-"), "0") + ) + assert await integration_setup() + + states = hass.states.async_all() + assert {s.entity_id for s in states} == { + "sensor.activity_calories", + "sensor.calories", + "sensor.distance", + "sensor.elevation", + "sensor.floors", + "sensor.minutes_fairly_active", + "sensor.minutes_lightly_active", + "sensor.minutes_sedentary", + "sensor.minutes_very_active", + "sensor.steps", + } + + +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_heartrate_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], +) -> None: + """Test heartrate sensors are enabled.""" + + register_timeseries( + "activities/heart", + timeseries_response("activities-heart", {"restingHeartRate": "0"}), + ) + assert await integration_setup() + + states = hass.states.async_all() + assert {s.entity_id for s in states} == { + "sensor.resting_heart_rate", + } + + +@pytest.mark.parametrize( + ("scopes", "unit_system"), + [(["nutrition"], METRIC_SYSTEM), (["nutrition"], US_CUSTOMARY_SYSTEM)], +) +async def test_nutrition_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + unit_system: UnitSystem, + snapshot: SnapshotAssertion, +) -> None: + """Test nutrition sensors are enabled.""" + hass.config.units = unit_system + register_timeseries( + "foods/log/water", + timeseries_response("foods-log-water", "99"), + ) + register_timeseries( + "foods/log/caloriesIn", + timeseries_response("foods-log-caloriesIn", "1600"), + ) + assert await integration_setup() + + state = hass.states.get("sensor.water") + assert state + assert (state.state, state.attributes) == snapshot + + state = hass.states.get("sensor.calories_in") + assert state + assert (state.state, state.attributes) == snapshot + + +@pytest.mark.parametrize( + ("scopes"), + [(["sleep"])], +) +async def test_sleep_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], +) -> None: + """Test sleep sensors are enabled.""" + + for api_resource in ( + "sleep/startTime", + "sleep/timeInBed", + "sleep/minutesToFallAsleep", + "sleep/minutesAwake", + "sleep/minutesAsleep", + "sleep/minutesAfterWakeup", + "sleep/efficiency", + "sleep/awakeningsCount", + ): + register_timeseries( + api_resource, + timeseries_response(api_resource.replace("/", "-"), "0"), + ) + assert await integration_setup() + + states = hass.states.async_all() + assert {s.entity_id for s in states} == { + "sensor.awakenings_count", + "sensor.sleep_efficiency", + "sensor.minutes_after_wakeup", + "sensor.sleep_minutes_asleep", + "sensor.sleep_minutes_awake", + "sensor.sleep_minutes_to_fall_asleep", + "sensor.sleep_time_in_bed", + "sensor.sleep_start_time", + } + + +@pytest.mark.parametrize( + ("scopes"), + [(["weight"])], +) +async def test_weight_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], +) -> None: + """Test sleep sensors are enabled.""" + + register_timeseries("body/weight", timeseries_response("body-weight", "0")) + assert await integration_setup() + + states = hass.states.async_all() + assert [s.entity_id for s in states] == [ + "sensor.weight", + ] + + +@pytest.mark.parametrize( + ("scopes", "devices_response"), + [(["settings"], [DEVICE_RESPONSE_CHARGE_2])], +) +async def test_settings_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], +) -> None: + """Test device sensors are enabled.""" + + assert await integration_setup() + + states = hass.states.async_all() + assert [s.entity_id for s in states] == [ + "sensor.charge_2_battery", + ] + + +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_sensor_update_failed( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + requests_mock: Mocker, +) -> None: + """Test a failed sensor update when talking to the API.""" + + requests_mock.register_uri( + "GET", + TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == "unavailable" + + # Verify the config entry is in a normal state (no reauth required) + flows = hass.config_entries.flow.async_progress() + assert not flows + + +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_sensor_update_failed_requires_reauth( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + requests_mock: Mocker, +) -> None: + """Test a sensor update request requires reauth.""" + + requests_mock.register_uri( + "GET", + TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), + status_code=HTTPStatus.UNAUTHORIZED, + json={ + "errors": [{"errorType": "invalid_grant"}], + }, + ) + + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == "unavailable" + + # Verify that reauth is required + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_sensor_update_success( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + requests_mock: Mocker, +) -> None: + """Test API failure for a battery level sensor for devices.""" + + requests_mock.register_uri( + "GET", + TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), + [ + { + "status_code": HTTPStatus.OK, + "json": timeseries_response( + "activities-heart", {"restingHeartRate": "60"} + ), + }, + { + "status_code": HTTPStatus.OK, + "json": timeseries_response( + "activities-heart", {"restingHeartRate": "70"} + ), + }, + ], + ) + + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == "60" + + await async_update_entity(hass, "sensor.resting_heart_rate") + await hass.async_block_till_done() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == "70" + + +@pytest.mark.parametrize( + ("scopes", "mock_devices"), + [(["settings"], None)], +) +async def test_device_battery_level_update_failed( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + requests_mock: Mocker, +) -> None: + """Test API failure for a battery level sensor for devices.""" + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + [ + { + "status_code": HTTPStatus.OK, + "json": [DEVICE_RESPONSE_CHARGE_2], + }, + # Fail when requesting an update + { + "status_code": HTTPStatus.INTERNAL_SERVER_ERROR, + "json": { + "errors": [ + { + "errorType": "request", + "message": "An error occurred", + } + ] + }, + }, + ], + ) + + assert await integration_setup() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "Medium" + + # Request an update for the entity which will fail + await async_update_entity(hass, "sensor.charge_2_battery") + await hass.async_block_till_done() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "unavailable" + + # Verify the config entry is in a normal state (no reauth required) + flows = hass.config_entries.flow.async_progress() + assert not flows + + +@pytest.mark.parametrize( + ("scopes", "mock_devices"), + [(["settings"], None)], +) +async def test_device_battery_level_reauth_required( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + requests_mock: Mocker, +) -> None: + """Test API failure requires reauth.""" + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + [ + { + "status_code": HTTPStatus.OK, + "json": [DEVICE_RESPONSE_CHARGE_2], + }, + # Fail when requesting an update + { + "status_code": HTTPStatus.UNAUTHORIZED, + "json": { + "errors": [{"errorType": "invalid_grant"}], + }, + }, + ], + ) + + assert await integration_setup() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "Medium" + + # Request an update for the entity which will fail + await async_update_entity(hass, "sensor.charge_2_battery") + await hass.async_block_till_done() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "unavailable" + + # Verify that reauth is required + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 0192ea7bb00..5511b93ac3f 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -96,7 +96,7 @@ async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> No assert state assert state.state == STATE_ON assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_BRIGHTNESS] == 100 + assert ATTR_BRIGHTNESS not in state.attributes assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 5a757da1e9c..c64972b7904 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -1,6 +1,10 @@ """Tests for the Fronius integration.""" from __future__ import annotations +from collections.abc import Callable +import json +from typing import Any + from homeassistant.components.fronius.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -32,55 +36,78 @@ async def setup_fronius_integration( return entry +def _load_and_patch_fixture( + override_data: dict[str, list[tuple[list[str], Any]]] +) -> Callable[[str, str | None], str]: + """Return a fixture loader that patches values at nested keys for a given filename.""" + + def load_and_patch(filename: str, integration: str): + """Load a fixture and patch given values.""" + text = load_fixture(filename, integration) + if filename not in override_data: + return text + + _loaded = json.loads(text) + for keys, value in override_data[filename]: + _dic = _loaded + for key in keys[:-1]: + _dic = _dic[key] + _dic[keys[-1]] = value + return json.dumps(_loaded) + + return load_and_patch + + def mock_responses( aioclient_mock: AiohttpClientMocker, host: str = MOCK_HOST, fixture_set: str = "symo", inverter_ids: list[str | int] = [1], night: bool = False, + override_data: dict[str, list[tuple[list[str], Any]]] + | None = None, # {filename: [([list of nested keys], patch_value)]} ) -> None: """Mock responses for Fronius devices.""" aioclient_mock.clear_requests() _night = "_night" if night else "" + _load = _load_and_patch_fixture(override_data) if override_data else load_fixture aioclient_mock.get( f"{host}/solar_api/GetAPIVersion.cgi", - text=load_fixture(f"{fixture_set}/GetAPIVersion.json", "fronius"), + text=_load(f"{fixture_set}/GetAPIVersion.json", "fronius"), ) for inverter_id in inverter_ids: aioclient_mock.get( f"{host}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&" f"DeviceId={inverter_id}&DataCollection=CommonInverterData", - text=load_fixture( + text=_load( f"{fixture_set}/GetInverterRealtimeData_Device_{inverter_id}{_night}.json", "fronius", ), ) aioclient_mock.get( f"{host}/solar_api/v1/GetInverterInfo.cgi", - text=load_fixture(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), + text=_load(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetLoggerInfo.cgi", - text=load_fixture(f"{fixture_set}/GetLoggerInfo.json", "fronius"), + text=_load(f"{fixture_set}/GetLoggerInfo.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetMeterRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetMeterRealtimeData.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetPowerFlowRealtimeData.fcgi", - text=load_fixture( - f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius" - ), + text=_load(f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetStorageRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetStorageRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetStorageRealtimeData.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetOhmPilotRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), ) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index c2e0c4ad969..f94b0f3a55c 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -302,6 +302,22 @@ async def test_gen24( assert_state("sensor.solarnet_relative_autonomy", 5.3592) assert_state("sensor.solarnet_total_energy", 1530193.42) + # Gen24 devices may report 0 for total energy while doing firmware updates. + # This should yield "unknown" state instead of 0. + mock_responses( + aioclient_mock, + fixture_set="gen24", + override_data={ + "gen24/GetInverterRealtimeData_Device_1.json": [ + (["Body", "Data", "TOTAL_ENERGY", "Value"], 0), + ], + }, + ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert_state("sensor.inverter_name_total_energy", "unknown") + async def test_gen24_storage( hass: HomeAssistant, diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 721f6416154..e3f0d7f35d5 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,9 +8,8 @@ from unittest.mock import patch import pytest from homeassistant.components.frontend import ( - CONF_EXTRA_HTML_URL, - CONF_EXTRA_HTML_URL_ES5, - CONF_JS_VERSION, + CONF_EXTRA_JS_URL_ES5, + CONF_EXTRA_MODULE_URL, CONF_THEMES, DEFAULT_THEME_COLOR, DOMAIN, @@ -107,16 +106,15 @@ async def ws_client(hass, hass_ws_client, frontend): @pytest.fixture -async def mock_http_client_with_urls(hass, aiohttp_client, ignore_frontend_deps): +async def mock_http_client_with_extra_js(hass, aiohttp_client, ignore_frontend_deps): """Start the Home Assistant HTTP component.""" assert await async_setup_component( hass, "frontend", { DOMAIN: { - CONF_JS_VERSION: "auto", - CONF_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"], - CONF_EXTRA_HTML_URL_ES5: ["https://domain.com/my_extra_url_es5.html"], + CONF_EXTRA_MODULE_URL: ["/local/my_module.js"], + CONF_EXTRA_JS_URL_ES5: ["/local/my_es5.js"], } }, ) @@ -177,15 +175,22 @@ async def test_themes_api(hass: HomeAssistant, themes_ws_client) -> None: assert msg["result"]["default_dark_theme"] is None assert msg["result"]["themes"] == MOCK_THEMES - # safe mode - hass.config.safe_mode = True + # recovery mode + hass.config.recovery_mode = True await themes_ws_client.send_json({"id": 6, "type": "frontend/get_themes"}) msg = await themes_ws_client.receive_json() - assert msg["result"]["default_theme"] == "safe_mode" - assert msg["result"]["themes"] == { - "safe_mode": {"primary-color": "#db4437", "accent-color": "#ffca28"} - } + assert msg["result"]["default_theme"] == "default" + assert msg["result"]["themes"] == {} + + # safe mode + hass.config.recovery_mode = False + hass.config.safe_mode = True + await themes_ws_client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() + + assert msg["result"]["default_theme"] == "default" + assert msg["result"]["themes"] == {} async def test_themes_persist( @@ -376,6 +381,29 @@ async def test_missing_themes(hass: HomeAssistant, ws_client) -> None: assert msg["result"]["themes"] == {} +async def test_extra_js( + hass: HomeAssistant, mock_http_client_with_extra_js, mock_onboarded +): + """Test that extra javascript is loaded.""" + resp = await mock_http_client_with_extra_js.get("") + assert resp.status == 200 + assert "cache-control" not in resp.headers + + text = await resp.text() + assert '"/local/my_module.js"' in text + assert '"/local/my_es5.js"' in text + + # safe mode + hass.config.safe_mode = True + resp = await mock_http_client_with_extra_js.get("") + assert resp.status == 200 + assert "cache-control" not in resp.headers + + text = await resp.text() + assert '"/local/my_module.js"' not in text + assert '"/local/my_es5.js"' not in text + + async def test_get_panels( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_http_client ) -> None: diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index a3ecff80a46..ae0bb9ace09 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -21,6 +21,7 @@ 'model': 'Mock Model', 'name': 'Mock Title', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.2.3', 'via_device_id': None, diff --git a/tests/components/gdacs/test_config_flow.py b/tests/components/gdacs/test_config_flow.py index 88641b69bd2..f8dfa0cd7fd 100644 --- a/tests/components/gdacs/test_config_flow.py +++ b/tests/components/gdacs/test_config_flow.py @@ -12,7 +12,8 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir @pytest.fixture(name="gdacs_setup", autouse=True) @@ -66,6 +67,32 @@ async def test_step_import(hass: HomeAssistant) -> None: CONF_CATEGORIES: ["Drought", "Earthquake"], } + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_gdacs" + ) + assert issue.translation_key == "deprecated_yaml" + + +async def test_step_import_already_exist( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> None: + """Test that errors are shown when duplicates are added.""" + conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25} + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_gdacs" + ) + assert issue.translation_key == "deprecated_yaml" + async def test_step_user(hass: HomeAssistant) -> None: """Test that the user step works.""" diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index f7f7c390e0d..aecfcbc29c1 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,6 +1,7 @@ """The tests for generic camera component.""" import asyncio from http import HTTPStatus +import sys from unittest.mock import patch import aiohttp @@ -163,10 +164,17 @@ async def test_limit_refetch( hass.states.async_set("sensor.temp", "5") - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "async_timeout.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") + # TODO: Remove version check with aiohttp 3.9.0 + if sys.version_info >= (3, 12): + with pytest.raises(aiohttp.ServerTimeoutError), patch( + "asyncio.timeout", side_effect=asyncio.TimeoutError() + ): + resp = await client.get("/api/camera_proxy/camera.config_test") + else: + with pytest.raises(aiohttp.ServerTimeoutError), patch( + "async_timeout.timeout", side_effect=asyncio.TimeoutError() + ): + resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 1 assert resp.status == HTTPStatus.OK diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index e3fb26ffe22..bd97a683989 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -31,6 +31,7 @@ from homeassistant.core import ( State, callback, ) +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -169,6 +170,33 @@ async def test_humidifier_switch( assert hass.states.get(ENTITY).attributes.get("action") == "humidifying" +async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: + """Test setting a unique ID.""" + unique_id = "some_unique_id" + _setup_sensor(hass, 18) + await _setup_switch(hass, True) + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "unique_id": unique_id, + } + }, + ) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get(ENTITY) + assert entry + assert entry.unique_id == unique_id + + def _setup_sensor(hass, humidity): """Set up the test sensor.""" hass.states.async_set(ENT_SENSOR, humidity) diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 4eb2e3ce711..2a406ddbd79 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -174,7 +174,7 @@ async def test_heater_switch( async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: - """Test heater switching input_boolean.""" + """Test setting a unique ID.""" unique_id = "some_unique_id" _setup_sensor(hass, 18) _setup_switch(hass, True) diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 90b1489803a..d36d692422e 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -33,41 +33,41 @@ async def test_sensors( """Test we get sensor data.""" await async_init_integration(hass, aioclient_mock) - state = hass.states.get(f"sensor.{DEFAULT_NAME}_watts_in") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_power_in") assert state.state == "0.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - state = hass.states.get(f"sensor.{DEFAULT_NAME}_amps_in") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_current_in") assert state.state == "0.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.AMPERE ) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - state = hass.states.get(f"sensor.{DEFAULT_NAME}_watts_out") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_power_out") assert state.state == "50.5" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - state = hass.states.get(f"sensor.{DEFAULT_NAME}_amps_out") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_current_out") assert state.state == "2.1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.AMPERE ) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - state = hass.states.get(f"sensor.{DEFAULT_NAME}_wh_out") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_energy_out") assert state.state == "5.23" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.WATT_HOUR assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - state = hass.states.get(f"sensor.{DEFAULT_NAME}_wh_stored") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_energy_stored") assert state.state == "1330" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.WATT_HOUR assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - state = hass.states.get(f"sensor.{DEFAULT_NAME}_volts") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_voltage") assert state.state == "12.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE assert ( diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 57e542e8a21..d938a2f3291 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -58,6 +58,35 @@ TEST_API_CALENDAR = { "defaultReminders": [], } +TEST_EVENT = { + "summary": "Test All Day Event", + "start": {}, + "end": {}, + "location": "Test Cases", + "description": "test event", + "kind": "calendar#event", + "created": "2016-06-23T16:37:57.000Z", + "transparency": "transparent", + "updated": "2016-06-24T01:57:21.045Z", + "reminders": {"useDefault": True}, + "organizer": { + "email": "uvrttabwegnui4gtia3vyqb@import.calendar.google.com", + "displayName": "Organizer Name", + "self": True, + }, + "sequence": 0, + "creator": { + "email": "uvrttabwegnui4gtia3vyqb@import.calendar.google.com", + "displayName": "Organizer Name", + "self": True, + }, + "id": "_c8rinwq863h45qnucyoi43ny8", + "etag": '"2933466882090000"', + "htmlLink": "https://www.google.com/calendar/event?eid=*******", + "iCalUID": "cydrevtfuybguinhomj@google.com", + "status": "confirmed", +} + CLIENT_ID = "client-id" CLIENT_SECRET = "client-secret" @@ -232,7 +261,7 @@ def mock_events_list( @pytest.fixture def mock_events_list_items( mock_events_list: Callable[[dict[str, Any]], None] -) -> Callable[list[[dict[str, Any]]], None]: +) -> Callable[[list[dict[str, Any]]], None]: """Fixture to construct an API response containing event items.""" def _put_items(items: list[dict[str, Any]]) -> None: diff --git a/tests/components/google/snapshots/test_diagnostics.ambr b/tests/components/google/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..6ac9d6c508d --- /dev/null +++ b/tests/components/google/snapshots/test_diagnostics.ambr @@ -0,0 +1,80 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'now': '2023-03-13T13:05:00-06:00', + 'store': dict({ + 'calendar#1': dict({ + 'events': list([ + dict({ + 'attendees': '**REDACTED**', + 'attendees_omitted': False, + 'description': '**REDACTED**', + 'end': dict({ + 'date': None, + 'date_time': '2023-03-13T12:30:00-07:00', + 'timezone': None, + }), + 'event_type': 'default', + 'ical_uuid': '**REDACTED**', + 'id': '**REDACTED**', + 'location': '**REDACTED**', + 'original_start_time': None, + 'recurrence': list([ + ]), + 'recurring_event_id': None, + 'reminders': dict({ + 'overrides': list([ + ]), + 'use_default': True, + }), + 'start': dict({ + 'date': None, + 'date_time': '2023-03-13T12:00:00-07:00', + 'timezone': None, + }), + 'status': 'confirmed', + 'summary': '**REDACTED**', + 'transparency': 'transparent', + 'visibility': 'default', + }), + dict({ + 'attendees': '**REDACTED**', + 'attendees_omitted': False, + 'description': '**REDACTED**', + 'end': dict({ + 'date': '2022-10-09', + 'date_time': None, + 'timezone': None, + }), + 'event_type': 'default', + 'ical_uuid': '**REDACTED**', + 'id': '**REDACTED**', + 'location': '**REDACTED**', + 'original_start_time': None, + 'recurrence': list([ + 'RRULE:FREQ=WEEKLY', + ]), + 'recurring_event_id': None, + 'reminders': dict({ + 'overrides': list([ + ]), + 'use_default': True, + }), + 'start': dict({ + 'date': '2022-10-08', + 'date_time': None, + 'timezone': None, + }), + 'status': 'confirmed', + 'summary': '**REDACTED**', + 'transparency': 'transparent', + 'visibility': 'default', + }), + ]), + 'sync_token_version': 2, + }), + }), + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index d6431700fca..3a9673441c0 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -23,6 +23,7 @@ from .conftest import ( CALENDAR_ID, TEST_API_ENTITY, TEST_API_ENTITY_NAME, + TEST_EVENT, TEST_YAML_ENTITY, TEST_YAML_ENTITY_NAME, ApiResult, @@ -36,35 +37,6 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator TEST_ENTITY = TEST_API_ENTITY TEST_ENTITY_NAME = TEST_API_ENTITY_NAME -TEST_EVENT = { - "summary": "Test All Day Event", - "start": {}, - "end": {}, - "location": "Test Cases", - "description": "test event", - "kind": "calendar#event", - "created": "2016-06-23T16:37:57.000Z", - "transparency": "transparent", - "updated": "2016-06-24T01:57:21.045Z", - "reminders": {"useDefault": True}, - "organizer": { - "email": "uvrttabwegnui4gtia3vyqb@import.calendar.google.com", - "displayName": "Organizer Name", - "self": True, - }, - "sequence": 0, - "creator": { - "email": "uvrttabwegnui4gtia3vyqb@import.calendar.google.com", - "displayName": "Organizer Name", - "self": True, - }, - "id": "_c8rinwq863h45qnucyoi43ny8", - "etag": '"2933466882090000"', - "htmlLink": "https://www.google.com/calendar/event?eid=*******", - "iCalUID": "cydrevtfuybguinhomj@google.com", - "status": "confirmed", -} - @pytest.fixture(autouse=True) def mock_test_setup( diff --git a/tests/components/google/test_diagnostics.py b/tests/components/google/test_diagnostics.py new file mode 100644 index 00000000000..5ebc683485b --- /dev/null +++ b/tests/components/google/test_diagnostics.py @@ -0,0 +1,106 @@ +"""Tests for diagnostics platform of google calendar.""" +from collections.abc import Callable +from typing import Any + +from aiohttp.test_utils import TestClient +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.auth.models import Credentials +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import TEST_EVENT, ComponentSetup + +from tests.common import CLIENT_ID, MockConfigEntry, MockUser +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def mock_test_setup( + test_api_calendar, + mock_calendars_list, +): + """Fixture that sets up the default API responses during integration setup.""" + mock_calendars_list({"items": [test_api_calendar]}) + + +async def generate_new_hass_access_token( + hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials +) -> str: + """Return an access token to access Home Assistant.""" + await hass.auth.async_link_user(hass_admin_user, hass_admin_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID, credential=hass_admin_credential + ) + return hass.auth.async_create_access_token(refresh_token) + + +def _get_test_client_generator( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, new_token: str +): + """Return a test client generator."".""" + + async def auth_client() -> TestClient: + return await aiohttp_client( + hass.http.app, headers={"Authorization": f"Bearer {new_token}"} + ) + + return auth_client + + +@pytest.fixture(autouse=True) +async def setup_diag(hass): + """Set up diagnostics platform.""" + assert await async_setup_component(hass, "diagnostics", {}) + + +@freeze_time("2023-03-13 12:05:00-07:00") +async def test_diagnostics( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + hass_admin_user: MockUser, + hass_admin_credential: Credentials, + config_entry: MockConfigEntry, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for the calendar.""" + mock_events_list_items( + [ + { + **TEST_EVENT, + "id": "event-id-1", + "iCalUID": "event-id-1@google.com", + "start": {"dateTime": "2023-03-13 12:00:00-07:00"}, + "end": {"dateTime": "2023-03-13 12:30:00-07:00"}, + }, + { + **TEST_EVENT, + "id": "event-id-2", + "iCalUID": "event-id-2@google.com", + "summary": "All Day Event", + "start": {"date": "2022-10-08"}, + "end": {"date": "2022-10-09"}, + "recurrence": ["RRULE:FREQ=WEEKLY"], + }, + ] + ) + + assert await component_setup() + + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + data = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) + assert data == snapshot diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index db4257bb621..903ba5ca036 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -223,7 +223,7 @@ async def test_onoff_input_boolean(hass: HomeAssistant) -> None: @pytest.mark.freeze_time("2023-08-01T00:02:57+00:00") async def test_doorbell_event(hass: HomeAssistant) -> None: - """Test doorbell event trait support for input_boolean domain.""" + """Test doorbell event trait support for event domain.""" assert trait.ObjectDetection.supported(event.DOMAIN, 0, "doorbell", None) state = State( diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index a069ae0807b..4882fd10e80 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -73,8 +73,13 @@ async def test_expired_token_refresh_success( http.HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY, ), + ( + time.time() - 3600, + http.HTTPStatus.BAD_REQUEST, + ConfigEntryState.SETUP_ERROR, + ), ], - ids=["failure_requires_reauth", "transient_failure"], + ids=["failure_requires_reauth", "transient_failure", "revoked_auth"], ) async def test_expired_token_refresh_failure( hass: HomeAssistant, diff --git a/tests/components/google_mail/test_services.py b/tests/components/google_mail/test_services.py index b9fefa805e6..caa0d887dec 100644 --- a/tests/components/google_mail/test_services.py +++ b/tests/components/google_mail/test_services.py @@ -1,12 +1,14 @@ """Services tests for the Google Mail integration.""" from unittest.mock import patch +from aiohttp.client_exceptions import ClientResponseError from google.auth.exceptions import RefreshError import pytest from homeassistant import config_entries from homeassistant.components.google_mail import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import BUILD, SENSOR, TOKEN, ComponentSetup @@ -57,13 +59,22 @@ async def test_set_vacation( assert len(mock_client.mock_calls) == 5 +@pytest.mark.parametrize( + ("side_effect"), + ( + (RefreshError,), + (ClientResponseError("", (), status=400),), + ), +) async def test_reauth_trigger( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + setup_integration: ComponentSetup, + side_effect, ) -> None: """Test reauth is triggered after a refresh error during service call.""" await setup_integration() - with patch(TOKEN, side_effect=RefreshError), pytest.raises(RefreshError): + with patch(TOKEN, side_effect=side_effect), pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, "set_vacation", diff --git a/tests/components/google_tasks/__init__.py b/tests/components/google_tasks/__init__.py new file mode 100644 index 00000000000..6a6872a350a --- /dev/null +++ b/tests/components/google_tasks/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Tasks integration.""" diff --git a/tests/components/google_tasks/conftest.py b/tests/components/google_tasks/conftest.py new file mode 100644 index 00000000000..60387889aad --- /dev/null +++ b/tests/components/google_tasks/conftest.py @@ -0,0 +1,91 @@ +"""Test fixtures for Google Tasks.""" + + +from collections.abc import Awaitable, Callable +import time +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_tasks.const import DOMAIN, OAUTH2_SCOPES +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" +FAKE_AUTH_IMPL = "conftest-imported-cred" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="token_entry") +def mock_token_entry(expires_at: int) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(OAUTH2_SCOPES), + "token_type": "Bearer", + "expires_at": expires_at, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": token_entry, + }, + ) + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platforms: list[str], +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run diff --git a/tests/components/google_tasks/fixtures/api_not_enabled_response.json b/tests/components/google_tasks/fixtures/api_not_enabled_response.json new file mode 100644 index 00000000000..75ecfddab20 --- /dev/null +++ b/tests/components/google_tasks/fixtures/api_not_enabled_response.json @@ -0,0 +1,15 @@ +{ + "error": { + "code": 403, + "message": "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "errors": [ + { + "message": "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "domain": "usageLimits", + "reason": "accessNotConfigured", + "extendedHelp": "https://console.developers.google.com" + } + ], + "status": "PERMISSION_DENIED" + } +} diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr new file mode 100644 index 00000000000..f24d17a60d1 --- /dev/null +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_create_todo_list_item[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[api_responses0].1 + '{"title": "Soda", "status": "needsAction"}' +# --- +# name: test_partial_update_status[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update_status[api_responses0].1 + '{"status": "needsAction"}' +# --- +# name: test_partial_update_title[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update_title[api_responses0].1 + '{"title": "Soda"}' +# --- +# name: test_update_todo_list_item[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_update_todo_list_item[api_responses0].1 + '{"title": "Soda", "status": "completed"}' +# --- diff --git a/tests/components/google_tasks/test_config_flow.py b/tests/components/google_tasks/test_config_flow.py new file mode 100644 index 00000000000..e92da605697 --- /dev/null +++ b/tests/components/google_tasks/test_config_flow.py @@ -0,0 +1,183 @@ +"""Test the Google Tasks config flow.""" + +from unittest.mock import patch + +from googleapiclient.errors import HttpError +from httplib2 import Response + +from homeassistant import config_entries +from homeassistant.components.google_tasks.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.common import load_fixture + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.async_setup_entry", return_value=True + ) as mock_setup, patch("homeassistant.components.google_tasks.config_flow.build"): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +async def test_api_not_enabled( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check flow aborts if api is not enabled.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.config_flow.build", + side_effect=HttpError( + Response({"status": "403"}), + bytes(load_fixture("google_tasks/api_not_enabled_response.json"), "utf-8"), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "access_not_configured" + assert ( + result["description_placeholders"]["message"] + == "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + ) + + +async def test_general_exception( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check flow aborts if exception happens.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.config_flow.build", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" diff --git a/tests/components/google_tasks/test_init.py b/tests/components/google_tasks/test_init.py new file mode 100644 index 00000000000..b486942f70a --- /dev/null +++ b/tests/components/google_tasks/test_init.py @@ -0,0 +1,99 @@ +"""Tests for Google Tasks.""" +from collections.abc import Awaitable, Callable +import http +import time + +import pytest + +from homeassistant.components.google_tasks import DOMAIN +from homeassistant.components.google_tasks.const import OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test successful setup and unload.""" + assert config_entry.state is ConfigEntryState.NOT_LOADED + + await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert not hass.services.async_services().get(DOMAIN) + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await integration_setup() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data["token"]["access_token"] == "updated-access-token" + assert config_entry.data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_RETRY, # Will trigger reauth in the future + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["unauthorized", "internal_server_error"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + setup_credentials: None, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await integration_setup() + + assert config_entry.state is expected_state diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py new file mode 100644 index 00000000000..e19ac1272cd --- /dev/null +++ b/tests/components/google_tasks/test_todo.py @@ -0,0 +1,336 @@ +"""Tests for Google Tasks todo platform.""" + + +from collections.abc import Awaitable, Callable +import json +from typing import Any +from unittest.mock import Mock, patch + +from httplib2 import Response +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + +ENTITY_ID = "todo.my_tasks" +LIST_TASK_LIST_RESPONSE = { + "items": [ + { + "id": "task-list-id-1", + "title": "My tasks", + }, + ] +} +EMPTY_RESPONSE = {} +LIST_TASKS_RESPONSE = { + "items": [], +} + +LIST_TASKS_RESPONSE_WATER = { + "items": [ + {"id": "some-task-id", "title": "Water", "status": "needsAction"}, + ], +} + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.TODO] + + +@pytest.fixture +def ws_req_id() -> Callable[[], int]: + """Fixture for incremental websocket requests.""" + + id = 0 + + def next_id() -> int: + nonlocal id + id += 1 + return id + + return next_id + + +@pytest.fixture +async def ws_get_items( + hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + await client.send_json( + { + "id": id, + "type": "todo/item/list", + "entity_id": ENTITY_ID, + } + ) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get + + +@pytest.fixture(name="api_responses") +def mock_api_responses() -> list[dict | list]: + """Fixture for API responses to return during test.""" + return [] + + +@pytest.fixture(autouse=True) +def mock_http_response(api_responses: list[dict | list]) -> Mock: + """Fixture to fake out http2lib responses.""" + responses = [ + (Response({}), bytes(json.dumps(api_response), encoding="utf-8")) + for api_response in api_responses + ] + with patch("httplib2.Http.request", side_effect=responses) as mock_response: + yield mock_response + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + { + "items": [ + {"id": "task-1", "title": "Task 1", "status": "needsAction"}, + {"id": "task-2", "title": "Task 2", "status": "completed"}, + ], + }, + ] + ], +) +async def test_get_items( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test getting todo list items.""" + + assert await integration_setup() + + await hass_ws_client(hass) + + items = await ws_get_items() + assert items == [ + { + "uid": "task-1", + "summary": "Task 1", + "status": "needs_action", + }, + { + "uid": "task-2", + "summary": "Task 2", + "status": "completed", + }, + ] + + # State reflect that one task needs action + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + ] + ], +) +async def test_empty_todo_list( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test getting todo list items.""" + + assert await integration_setup() + + await hass_ws_client(hass) + + items = await ws_get_items() + assert items == [] + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # create + LIST_TASKS_RESPONSE, # refresh after create + ] + ], +) +async def test_create_todo_list_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Mock, + snapshot: SnapshotAssertion, +) -> None: + """Test for creating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Soda"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update + ] + ], +) +async def test_update_todo_list_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for updating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "some-task-id", "rename": "Soda", "status": "completed"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update + ] + ], +) +async def test_partial_update_title( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for partial update with title only.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "some-task-id", "rename": "Soda"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update + ] + ], +) +async def test_partial_update_status( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for partial update with status only.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "some-task-id", "status": "needs_action"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 9fb381d7d31..15132baf25a 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -257,6 +257,144 @@ async def test_options_flow_departure_time(hass: HomeAssistant, mock_config) -> } +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_DEPARTURE_TIME: "test", + }, + ), + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_ARRIVAL_TIME: "test", + }, + ), + ], +) +@pytest.mark.usefixtures("validate_config_entry") +async def test_reset_departure_time(hass: HomeAssistant, mock_config) -> None: + """Test resetting departure time.""" + result = await hass.config_entries.options.async_init( + mock_config.entry_id, data=None + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_TIME_TYPE: DEPARTURE_TIME, + }, + ) + + assert mock_config.options == { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + } + + +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_ARRIVAL_TIME: "test", + }, + ), + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_DEPARTURE_TIME: "test", + }, + ), + ], +) +@pytest.mark.usefixtures("validate_config_entry") +async def test_reset_arrival_time(hass: HomeAssistant, mock_config) -> None: + """Test resetting arrival time.""" + result = await hass.config_entries.options.async_init( + mock_config.entry_id, data=None + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_TIME_TYPE: ARRIVAL_TIME, + }, + ) + + assert mock_config.options == { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + } + + +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_LANGUAGE: "en", + CONF_AVOID: "tolls", + CONF_UNITS: UNITS_IMPERIAL, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: "test", + CONF_TRAFFIC_MODEL: "best_guess", + CONF_TRANSIT_MODE: "train", + CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", + }, + ) + ], +) +@pytest.mark.usefixtures("validate_config_entry") +async def test_reset_options_flow_fields(hass: HomeAssistant, mock_config) -> None: + """Test resetting options flow fields that are not time related to None.""" + result = await hass.config_entries.options.async_init( + mock_config.entry_id, data=None + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: "test", + }, + ) + + assert mock_config.options == { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_ARRIVAL_TIME: "test", + } + + @pytest.mark.usefixtures("validate_config_entry", "bypass_setup") async def test_dupe(hass: HomeAssistant) -> None: """Test setting up the same entry data twice is OK.""" diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index f1479cad3d3..568b98daec1 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -109,6 +109,7 @@ 'original_icon': None, 'original_name': 'fake-device-1', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'aabbcc112233', diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index 73056fcc465..d2b0a5fbf4e 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -85,6 +85,7 @@ 'original_icon': 'mdi:lightbulb', 'original_name': 'fake-device-1 Panel Light', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_Panel Light', @@ -113,6 +114,7 @@ 'original_icon': None, 'original_name': 'fake-device-1 Quiet', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_Quiet', @@ -141,6 +143,7 @@ 'original_icon': None, 'original_name': 'fake-device-1 Fresh Air', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_Fresh Air', @@ -169,6 +172,7 @@ 'original_icon': None, 'original_name': 'fake-device-1 XFan', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_XFan', @@ -197,6 +201,7 @@ 'original_icon': 'mdi:pine-tree', 'original_name': 'fake-device-1 Health mode', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_Health mode', diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index e659fbe4b8f..2e14c21f20a 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -450,11 +450,26 @@ async def test_backup_download_headers( async def test_stream(hassio_client, aioclient_mock: AiohttpClientMocker) -> None: """Verify that the request is a stream.""" - aioclient_mock.get("http://127.0.0.1/app/entrypoint.js") - await hassio_client.get("/api/hassio/app/entrypoint.js", data="test") + content_type = "multipart/form-data; boundary='--webkit'" + aioclient_mock.post("http://127.0.0.1/backups/new/upload") + resp = await hassio_client.post( + "/api/hassio/backups/new/upload", headers={"Content-Type": content_type} + ) + # Check we got right response + assert resp.status == HTTPStatus.OK assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader) +async def test_simple_get_no_stream( + hassio_client, aioclient_mock: AiohttpClientMocker +) -> None: + """Verify that a simple GET request is not a stream.""" + aioclient_mock.get("http://127.0.0.1/app/entrypoint.js") + resp = await hassio_client.get("/api/hassio/app/entrypoint.js") + assert resp.status == HTTPStatus.OK + assert aioclient_mock.mock_calls[-1][2] is None + + async def test_entrypoint_cache_control( hassio_client, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index adb462b02e3..4bf3e29154e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -17,6 +17,7 @@ from homeassistant.components.hassio import ( async_get_addon_store_info, hostname_from_addon_slug, ) +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -244,7 +245,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -289,7 +290,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -308,7 +309,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -325,7 +326,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -405,7 +406,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -422,7 +423,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -442,7 +443,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -524,14 +525,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 26 + assert aioclient_mock.call_count == 24 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 28 + assert aioclient_mock.call_count == 26 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -546,7 +547,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 30 + assert aioclient_mock.call_count == 28 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -571,7 +572,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 32 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -585,14 +586,16 @@ async def test_service_calls( { "name": "backup_name", "location": "backup_share", + "homeassistant_exclude_database": True, }, ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 33 + assert aioclient_mock.call_count == 31 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", + "homeassistant_exclude_database": True, } await hass.services.async_call( @@ -604,7 +607,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 34 + assert aioclient_mock.call_count == 32 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -622,7 +625,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 36 + assert aioclient_mock.call_count == 34 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -690,6 +693,7 @@ async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Call core service and check the API calls behind that.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "hassio", {}) aioclient_mock.post("http://127.0.0.1/homeassistant/restart", json={"result": "ok"}) @@ -893,6 +897,7 @@ async def test_coordinator_updates( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + # Initial refresh without stats assert refresh_updates_mock.call_count == 1 with patch( @@ -916,10 +921,12 @@ async def test_coordinator_updates( }, blocking=True, ) - assert refresh_updates_mock.call_count == 1 + assert refresh_updates_mock.call_count == 0 - # There is a 10s cooldown on the debouncer - async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) await hass.async_block_till_done() with patch( @@ -937,6 +944,88 @@ async def test_coordinator_updates( }, blocking=True, ) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 1 + assert "Error on Supervisor API: Unknown" in caplog.text + + +async def test_coordinator_updates_stats_entities_enabled( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry_enabled_by_default: None, +) -> None: + """Test coordinator updates with stats entities enabled.""" + await async_setup_component(hass, "homeassistant", {}) + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.refresh_updates" + ) as refresh_updates_mock: + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Initial refresh without stats + assert refresh_updates_mock.call_count == 1 + + # Refresh with stats once we know which ones are needed + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 2 + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + ) as refresh_updates_mock: + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 0 + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + ) as refresh_updates_mock: + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + assert refresh_updates_mock.call_count == 0 + + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + side_effect=HassioAPIError("Unknown"), + ) as refresh_updates_mock: + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() assert refresh_updates_mock.call_count == 1 assert "Error on Supervisor API: Unknown" in caplog.text @@ -970,7 +1059,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index d33c6697321..fbc6f08a1f5 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -1,23 +1,64 @@ """The tests for the hassio sensors.""" +from datetime import timedelta import os from unittest.mock import patch import pytest -from homeassistant.components.hassio import DOMAIN +from homeassistant.components.hassio import ( + DOMAIN, + HASSIO_UPDATE_INTERVAL, + HassioAPIError, +) +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock, request): +def mock_all(aioclient_mock: AiohttpClientMocker, request): """Mock all setup requests.""" + _install_default_mocks(aioclient_mock) + _install_test_addon_stats_mock(aioclient_mock) + + +def _install_test_addon_stats_mock(aioclient_mock: AiohttpClientMocker): + """Install mock to provide valid stats for the test addon.""" + aioclient_mock.get( + "http://127.0.0.1/addons/test/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + + +def _install_test_addon_stats_failure_mock(aioclient_mock: AiohttpClientMocker): + """Install mocks to raise an exception when fetching stats for the test addon.""" + aioclient_mock.get( + "http://127.0.0.1/addons/test/stats", + exc=HassioAPIError, + ) + + +def _install_default_mocks(aioclient_mock: AiohttpClientMocker): + """Install default mocks.""" 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"}) @@ -79,6 +120,7 @@ def mock_all(aioclient_mock, request): "version_latest": "2.0.1", "repository": "core", "url": "https://github.com/home-assistant/addons/test", + "icon": False, }, { "name": "test2", @@ -90,27 +132,12 @@ def mock_all(aioclient_mock, request): "version_latest": "3.2.0", "repository": "core", "url": "https://github.com", + "icon": False, }, ], }, }, ) - aioclient_mock.get( - "http://127.0.0.1/addons/test/stats", - json={ - "result": "ok", - "data": { - "cpu_percent": 0.99, - "memory_usage": 182611968, - "memory_limit": 3977146368, - "memory_percent": 4.59, - "network_rx": 362570232, - "network_tx": 82374138, - "blk_read": 46010945536, - "blk_write": 15051526144, - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/core/stats", json={ @@ -196,6 +223,7 @@ async def test_sensor( expected, aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test hassio OS and addons sensor.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -218,6 +246,90 @@ async def test_sensor( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the entity have the expected state. state = hass.states.get(entity_id) assert state.state == expected + + +@pytest.mark.parametrize( + ("entity_id", "expected"), + [ + ("sensor.test_cpu_percent", "0.99"), + ("sensor.test_memory_percent", "4.59"), + ], +) +async def test_stats_addon_sensor( + hass: HomeAssistant, + entity_id, + expected, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test stats addons sensor.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + aioclient_mock.clear_requests() + _install_default_mocks(aioclient_mock) + _install_test_addon_stats_failure_mock(aioclient_mock) + + async_fire_time_changed( + hass, dt_util.utcnow() + HASSIO_UPDATE_INTERVAL + timedelta(seconds=1) + ) + await hass.async_block_till_done() + + assert "Could not fetch stats" not in caplog.text + + aioclient_mock.clear_requests() + _install_default_mocks(aioclient_mock) + _install_test_addon_stats_mock(aioclient_mock) + + async_fire_time_changed( + hass, dt_util.utcnow() + HASSIO_UPDATE_INTERVAL + timedelta(seconds=1) + ) + await hass.async_block_till_done() + + # Enable the entity. + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + + # Verify that the entity have the expected state. + state = hass.states.get(entity_id) + assert state.state == expected + + aioclient_mock.clear_requests() + _install_default_mocks(aioclient_mock) + _install_test_addon_stats_failure_mock(aioclient_mock) + + async_fire_time_changed( + hass, dt_util.utcnow() + HASSIO_UPDATE_INTERVAL + timedelta(seconds=1) + ) + await hass.async_block_till_done() + + assert "Could not fetch stats" in caplog.text diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 3f12874ef52..42918b02266 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -1,16 +1,18 @@ """The tests for the hassio update entities.""" +from datetime import timedelta import os from unittest.mock import patch import pytest -from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio import DOMAIN, HassioAPIError +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -609,8 +611,13 @@ async def test_setting_up_core_update_when_addon_fails( await hass.async_block_till_done() assert result + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the core update entity does exist state = hass.states.get("update.home_assistant_core_update") assert state assert state.state == "on" - assert "Could not fetch stats for test: add-on is not running" in caplog.text diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 4c5643ae3ca..22b380a3249 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -11,7 +11,10 @@ from homeassistant import config import homeassistant.components as comps from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, + ATTR_SAFE_MODE, SERVICE_CHECK_CONFIG, + SERVICE_HOMEASSISTANT_RESTART, + SERVICE_HOMEASSISTANT_STOP, SERVICE_RELOAD_ALL, SERVICE_RELOAD_CORE_CONFIG, SERVICE_RELOAD_CUSTOM_TEMPLATES, @@ -22,8 +25,6 @@ from homeassistant.const import ( ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, EVENT_CORE_CONFIG_UPDATE, - SERVICE_HOMEASSISTANT_RESTART, - SERVICE_HOMEASSISTANT_STOP, SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -536,22 +537,32 @@ async def test_raises_when_config_is_invalid( assert mock_async_check_ha_config_file.called -async def test_restart_homeassistant(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("service_data", "safe_mode_enabled"), + [({}, False), ({ATTR_SAFE_MODE: False}, False), ({ATTR_SAFE_MODE: True}, True)], +) +async def test_restart_homeassistant( + hass: HomeAssistant, service_data: dict, safe_mode_enabled: bool +) -> None: """Test we can restart when there is no configuration error.""" await async_setup_component(hass, "homeassistant", {}) with patch( "homeassistant.config.async_check_ha_config_file", return_value=None ) as mock_check, patch( + "homeassistant.config.async_enable_safe_mode" + ) as mock_safe_mode, patch( "homeassistant.core.HomeAssistant.async_stop", return_value=None ) as mock_restart: await hass.services.async_call( "homeassistant", SERVICE_HOMEASSISTANT_RESTART, + service_data, blocking=True, ) assert mock_check.called await hass.async_block_till_done() assert mock_restart.called + assert mock_safe_mode.called == safe_mode_enabled async def test_stop_homeassistant(hass: HomeAssistant) -> None: diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 9e1977192e9..4d43d29463a 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components import homeassistant_sky_connect, usb from homeassistant.components.homeassistant_sky_connect.const import DOMAIN -from homeassistant.components.zha.core.const import ( +from homeassistant.components.zha import ( CONF_DEVICE_PATH, DOMAIN as ZHA_DOMAIN, RadioType, diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 18e654cb4ed..447cdc99a57 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -66,9 +66,9 @@ async def test_aid_generation( == 1751603975 ) - aid_storage.delete_aid(get_system_unique_id(light_ent)) - aid_storage.delete_aid(get_system_unique_id(light_ent2)) - aid_storage.delete_aid(get_system_unique_id(remote_ent)) + aid_storage.delete_aid(get_system_unique_id(light_ent, light_ent.unique_id)) + aid_storage.delete_aid(get_system_unique_id(light_ent2, light_ent2.unique_id)) + aid_storage.delete_aid(get_system_unique_id(remote_ent, remote_ent.unique_id)) aid_storage.delete_aid("non-existent-one") for _ in range(0, 2): @@ -618,3 +618,31 @@ async def test_aid_generation_no_unique_ids_handles_collision( aid_storage_path = hass.config.path(STORAGE_DIR, aidstore) if await hass.async_add_executor_job(os.path.exists, aid_storage_path): await hass.async_add_executor_job(os.unlink, aid_storage_path) + + +async def test_handle_unique_id_change( + hass: HomeAssistant, +) -> None: + """Test handling unique id changes.""" + entity_registry = er.async_get(hass) + light = entity_registry.async_get_or_create("light", "demo", "old_unique") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homekit.aidmanager.AccessoryAidStorage.async_schedule_save" + ): + aid_storage = AccessoryAidStorage(hass, config_entry) + await aid_storage.async_initialize() + + original_aid = aid_storage.get_or_allocate_aid_for_entity_id(light.entity_id) + assert aid_storage.allocations == {"demo.light.old_unique": 4202023227} + + entity_registry.async_update_entity(light.entity_id, new_unique_id="new_unique") + await hass.async_block_till_done() + + aid = aid_storage.get_or_allocate_aid_for_entity_id(light.entity_id) + assert aid == original_aid + + # Verify that the old unique id is removed from the allocations + # and that the new unique id assumes the old aid + assert aid_storage.allocations == {"demo.light.new_unique": 4202023227} diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 960647a22e6..179a0ce467f 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -22,6 +22,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, ) from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( ATTR_CODE, @@ -315,6 +316,13 @@ def test_type_sensors(type_name, entity_id, state, attrs) -> None: ("type_name", "entity_id", "state", "attrs", "config"), [ ("Outlet", "switch.test", "on", {}, {CONF_TYPE: TYPE_OUTLET}), + ( + "Outlet", + "switch.test", + "on", + {ATTR_DEVICE_CLASS: SwitchDeviceClass.OUTLET}, + {}, + ), ("Switch", "automation.test", "on", {}, {}), ("Switch", "button.test", STATE_UNKNOWN, {}, {}), ("Switch", "input_boolean.test", "on", {}, {}), diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 00281b491c4..158efa477d4 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -28,15 +28,21 @@ from homeassistant.components.homekit.const import ( CONF_ADVERTISE_IP, DEFAULT_PORT, DOMAIN, - HOMEKIT, HOMEKIT_MODE_ACCESSORY, HOMEKIT_MODE_BRIDGE, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_UNPAIR, ) +from homeassistant.components.homekit.models import HomeKitEntryData from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id +from homeassistant.components.light import ( + ATTR_COLOR_MODE, + ATTR_SUPPORTED_COLOR_MODES, + ColorMode, +) from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_ZEROCONF from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -532,7 +538,7 @@ async def test_homekit_remove_accessory( acc_mock.stop = AsyncMock() homekit.bridge.accessories = {6: acc_mock} - acc = await homekit.async_remove_bridge_accessory(6) + acc = homekit.async_remove_bridge_accessory(6) assert acc is acc_mock assert len(homekit.bridge.accessories) == 0 @@ -835,6 +841,7 @@ async def test_homekit_start_with_a_device( await async_init_entry(hass, entry) homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, None, devices=[device_id]) homekit.driver = hk_driver + homekit.aid_storage = MagicMock() with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=Exception), patch( f"{PATH_HOMEKIT}.async_show_setup_message" @@ -862,6 +869,7 @@ async def test_homekit_stop(hass: HomeAssistant) -> None: homekit.driver.async_stop = AsyncMock() homekit.bridge = Mock() homekit.bridge.accessories = {} + homekit.aid_storage = MagicMock() assert homekit.status == STATUS_READY await homekit.async_stop() @@ -876,6 +884,7 @@ async def test_homekit_stop(hass: HomeAssistant) -> None: # Test if driver is started homekit.status = STATUS_RUNNING + homekit._cancel_reload_dispatcher = lambda: None await homekit.async_stop() await hass.async_block_till_done() assert homekit.driver.async_stop.called is True @@ -919,6 +928,120 @@ async def test_homekit_reset_accessories( await homekit.async_stop() +async def test_homekit_reload_accessory_can_change_class( + hass: HomeAssistant, mock_async_zeroconf: None, mock_hap +) -> None: + """Test reloading a HomeKit Accessory in brdige mode. + + This test ensure when device class changes the HomeKit class changes. + """ + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "switch.outlet" + hass.states.async_set(entity_id, "on", {ATTR_DEVICE_CLASS: None}) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + await async_init_entry(hass, entry) + bridge: HomeBridge = homekit.driver.accessory + await bridge.run() + switch_accessory = next(iter(bridge.accessories.values())) + assert type(switch_accessory).__name__ == "Switch" + await hass.async_block_till_done() + assert homekit.status == STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() + hass.states.async_set( + entity_id, "off", {ATTR_DEVICE_CLASS: SwitchDeviceClass.OUTLET} + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + outlet_accessory = next(iter(bridge.accessories.values())) + assert type(outlet_accessory).__name__ == "Outlet" + + await homekit.async_stop() + + +async def test_homekit_reload_accessory_in_accessory_mode( + hass: HomeAssistant, mock_async_zeroconf: None, mock_hap +) -> None: + """Test reloading a HomeKit Accessory in accessory mode. + + This test ensure a device class changes can change the class of + the accessory. + """ + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "switch.outlet" + hass.states.async_set(entity_id, "on", {ATTR_DEVICE_CLASS: None}) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + await async_init_entry(hass, entry) + primary_accessory = homekit.driver.accessory + await primary_accessory.run() + assert type(primary_accessory).__name__ == "Switch" + await hass.async_block_till_done() + assert homekit.status == STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() + hass.states.async_set( + entity_id, "off", {ATTR_DEVICE_CLASS: SwitchDeviceClass.OUTLET} + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + primary_accessory = homekit.driver.accessory + assert type(primary_accessory).__name__ == "Outlet" + + await homekit.async_stop() + + +async def test_homekit_reload_accessory_same_class( + hass: HomeAssistant, mock_async_zeroconf: None, mock_hap +) -> None: + """Test reloading a HomeKit Accessory in bridge mode. + + The class of the accessory remains the same. + """ + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.color" + hass.states.async_set( + entity_id, + "on", + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_COLOR_MODE: ColorMode.HS}, + ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + await async_init_entry(hass, entry) + bridge: HomeBridge = homekit.driver.accessory + await bridge.run() + light_accessory_color = next(iter(bridge.accessories.values())) + assert not hasattr(light_accessory_color, "char_color_temp") + await hass.async_block_till_done() + assert homekit.status == STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() + hass.states.async_set( + entity_id, + "on", + { + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS, ColorMode.COLOR_TEMP], + ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, + }, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + light_accessory_color_and_temp = next(iter(bridge.accessories.values())) + assert hasattr(light_accessory_color_and_temp, "char_color_temp") + + await homekit.async_stop() + + async def test_homekit_unpair( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None ) -> None: @@ -1076,8 +1199,8 @@ async def test_homekit_reset_accessories_not_supported( 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" - ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" + ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 @@ -1101,7 +1224,7 @@ async def test_homekit_reset_accessories_not_supported( ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 2 + assert hk_driver_async_update_advertisement.call_count == 1 assert not mock_add_accessory.called assert len(homekit.bridge.accessories) == 0 homekit.status = STATUS_STOPPED @@ -1165,22 +1288,25 @@ async def test_homekit_reset_accessories_not_bridged( 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" - ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" + ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): await async_init_entry(hass, entry) + assert hk_driver_async_update_advertisement.call_count == 0 acc_mock = MagicMock() acc_mock.entity_id = entity_id acc_mock.stop = AsyncMock() + acc_mock.to_HAP = lambda: {} aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING homekit.driver.aio_stop_event = MagicMock() + assert hk_driver_async_update_advertisement.call_count == 0 await hass.services.async_call( DOMAIN, @@ -1190,7 +1316,7 @@ async def test_homekit_reset_accessories_not_bridged( ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 0 + assert hk_driver_async_update_advertisement.call_count == 0 assert not mock_add_accessory.called homekit.status = STATUS_STOPPED @@ -1208,8 +1334,8 @@ async def test_homekit_reset_single_accessory( homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( - "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" + ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch( f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" @@ -1226,7 +1352,7 @@ async def test_homekit_reset_single_accessory( ) await hass.async_block_till_done() assert mock_run.called - assert hk_driver_config_changed.call_count == 1 + assert hk_driver_async_update_advertisement.call_count == 1 homekit.status = STATUS_READY await homekit.async_stop() @@ -1675,10 +1801,8 @@ async def test_homekit_uses_system_zeroconf( entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert ( - hass.data[DOMAIN][entry.entry_id][HOMEKIT].driver.advertiser - == system_async_zc - ) + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry.entry_id] + assert entry_data.homekit.driver.advertiser == system_async_zc assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index c39a3ea97c9..df54cce1b3f 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -129,7 +129,14 @@ async def test_fan_direction(hass: HomeAssistant, hk_driver, events) -> None: await hass.async_block_till_done() assert acc.char_direction.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_DIRECTION: DIRECTION_REVERSE}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.DIRECTION, + ATTR_DIRECTION: DIRECTION_REVERSE, + }, + ) await hass.async_block_till_done() assert acc.char_direction.value == 1 @@ -197,7 +204,11 @@ async def test_fan_oscillate(hass: HomeAssistant, hk_driver, events) -> None: await hass.async_block_till_done() assert acc.char_swing.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_OSCILLATING: True}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_FEATURES: FanEntityFeature.OSCILLATE, ATTR_OSCILLATING: True}, + ) await hass.async_block_till_done() assert acc.char_swing.value == 1 @@ -272,7 +283,15 @@ async def test_fan_speed(hass: HomeAssistant, hk_driver, events) -> None: await acc.run() await hass.async_block_till_done() - hass.states.async_set(entity_id, STATE_ON, {ATTR_PERCENTAGE: 100}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_PERCENTAGE_STEP: 25, + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 100, + }, + ) await hass.async_block_till_done() assert acc.char_speed.value == 100 @@ -306,7 +325,15 @@ async def test_fan_speed(hass: HomeAssistant, hk_driver, events) -> None: assert events[-1].data[ATTR_VALUE] == 42 # Verify speed is preserved from off to on - hass.states.async_set(entity_id, STATE_OFF, {ATTR_PERCENTAGE: 42}) + hass.states.async_set( + entity_id, + STATE_OFF, + { + ATTR_PERCENTAGE_STEP: 25, + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + }, + ) await hass.async_block_till_done() assert acc.char_speed.value == 50 assert acc.char_active.value == 0 diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py index f3e4f96573d..c8c4f398375 100644 --- a/tests/components/homekit/test_type_humidifiers.py +++ b/tests/components/homekit/test_type_humidifiers.py @@ -1,4 +1,5 @@ """Test different accessory types: HumidifierDehumidifier.""" +from pyhap.accessory_driver import AccessoryDriver from pyhap.const import ( CATEGORY_HUMIDIFIER, HAP_REPR_AID, @@ -18,6 +19,7 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.components.homekit.type_humidifiers import HumidifierDehumidifier from homeassistant.components.humidifier import ( + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, @@ -48,7 +50,9 @@ async def test_humidifier(hass: HomeAssistant, hk_driver, events) -> None: """Test if humidifier accessory and HA are updated accordingly.""" entity_id = "humidifier.test" - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set( + entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: HumidifierDeviceClass.HUMIDIFIER} + ) await hass.async_block_till_done() acc = HumidifierDehumidifier( hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None @@ -73,11 +77,15 @@ async def test_humidifier(hass: HomeAssistant, hk_driver, events) -> None: assert acc.char_target_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { "Humidifier": 1 } - + assert acc.char_current_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { + "Humidifying": 2, + "Idle": 1, + "Inactive": 0, + } hass.states.async_set( entity_id, STATE_ON, - {ATTR_HUMIDITY: 47}, + {ATTR_HUMIDITY: 47, ATTR_DEVICE_CLASS: HumidifierDeviceClass.HUMIDIFIER}, ) await hass.async_block_till_done() assert acc.char_target_humidity.value == 47.0 @@ -154,11 +162,16 @@ async def test_dehumidifier(hass: HomeAssistant, hk_driver, events) -> None: assert acc.char_target_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { "Dehumidifier": 2 } + assert acc.char_current_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { + "Dehumidifying": 3, + "Idle": 1, + "Inactive": 0, + } hass.states.async_set( entity_id, STATE_ON, - {ATTR_HUMIDITY: 30}, + {ATTR_HUMIDITY: 30, ATTR_DEVICE_CLASS: HumidifierDeviceClass.DEHUMIDIFIER}, ) await hass.async_block_till_done() assert acc.char_target_humidity.value == 30.0 @@ -169,7 +182,7 @@ async def test_dehumidifier(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, STATE_OFF, - {ATTR_HUMIDITY: 42}, + {ATTR_HUMIDITY: 42, ATTR_DEVICE_CLASS: HumidifierDeviceClass.DEHUMIDIFIER}, ) await hass.async_block_till_done() assert acc.char_target_humidity.value == 42.0 @@ -521,3 +534,30 @@ async def test_dehumidifier_as_humidifier( await hass.async_block_till_done() assert "TargetHumidifierDehumidifierState is not supported" in caplog.text assert len(events) == 0 + + +async def test_humidifier_that_reports_current_humidity( + hass: HomeAssistant, hk_driver: AccessoryDriver +) -> None: + """Test a humidifier that provides current humidity can update.""" + entity_id = "humidifier.test" + hass.states.async_set(entity_id, STATE_OFF, {ATTR_CURRENT_HUMIDITY: 42}) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, + hk_driver, + "HumidifierDehumidifier", + entity_id, + 1, + {}, + ) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 42.0 + hass.states.async_set(entity_id, STATE_OFF, {ATTR_CURRENT_HUMIDITY: 43}) + + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 43.0 diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 53d310d8e40..6fae8337aae 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -122,7 +122,8 @@ async def test_light_basic(hass: HomeAssistant, hk_driver, events) -> None: @pytest.mark.parametrize( - "supported_color_modes", [["brightness"], ["hs"], ["color_temp"]] + "supported_color_modes", + [[ColorMode.BRIGHTNESS], [ColorMode.HS], [ColorMode.COLOR_TEMP]], ) async def test_light_brightness( hass: HomeAssistant, hk_driver, events, supported_color_modes @@ -149,7 +150,11 @@ async def test_light_brightness( await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 102}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 40 @@ -222,24 +227,48 @@ async def test_light_brightness( # 0 is a special case for homekit, see "Handle Brightness" # in update_state - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 0}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 1 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 255}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 255}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 0}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 1 # Ensure floats are handled - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 55.66}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 55.66}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 22 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 108.4}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 108.4}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 43 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0.0}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 0.0}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 1 @@ -490,7 +519,9 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_saturation.value == 100 -@pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) +@pytest.mark.parametrize( + "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] +) async def test_light_rgb_color( hass: HomeAssistant, hk_driver, events, supported_color_modes ) -> None: @@ -1000,6 +1031,40 @@ async def test_light_rgb_with_white_switch_to_temp( assert acc.char_brightness.value == 100 +async def test_light_rgb_with_hs_color_none( + hass: HomeAssistant, + hk_driver, + events, +) -> None: + """Test lights hs color set to None.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.RGB], + ATTR_RGBWW_COLOR: (128, 50, 0, 255, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: None, + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: ColorMode.RGB, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + await acc.run() + await hass.async_block_till_done() + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + assert acc.char_brightness.value == 100 + + async def test_light_rgbww_with_color_temp_conversion( hass: HomeAssistant, hk_driver, @@ -1221,7 +1286,7 @@ async def test_light_set_brightness_and_color( entity_id, STATE_ON, { - ATTR_SUPPORTED_COLOR_MODES: ["hs"], + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_BRIGHTNESS: 255, }, ) @@ -1241,11 +1306,19 @@ async def test_light_set_brightness_and_color( await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_BRIGHTNESS: 102}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 40 - hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (4.5, 9.2)}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_HS_COLOR: (4.5, 9.2)}, + ) await hass.async_block_till_done() assert acc.char_hue.value == 4 assert acc.char_saturation.value == 9 @@ -1297,7 +1370,7 @@ async def test_light_min_max_mireds(hass: HomeAssistant, hk_driver, events) -> N entity_id, STATE_ON, { - ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 255, ATTR_MAX_MIREDS: 500.5, ATTR_MIN_MIREDS: 100.5, @@ -1319,7 +1392,7 @@ async def test_light_set_brightness_and_color_temp( entity_id, STATE_ON, { - ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 255, }, ) @@ -1338,11 +1411,22 @@ async def test_light_set_brightness_and_color_temp( await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 102}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 40 - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: (4461)}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], + ATTR_COLOR_TEMP_KELVIN: (4461), + }, + ) await hass.async_block_till_done() assert acc.char_color_temp.value == 224 diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 3842303ec84..104b9dd61ce 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -56,11 +56,12 @@ async def test_media_player_set_state(hass: HomeAssistant, hk_driver, events) -> } } entity_id = "media_player.test" + base_attrs = {ATTR_SUPPORTED_FEATURES: 20873, ATTR_MEDIA_VOLUME_MUTED: False} hass.states.async_set( entity_id, None, - {ATTR_SUPPORTED_FEATURES: 20873, ATTR_MEDIA_VOLUME_MUTED: False}, + base_attrs, ) await hass.async_block_till_done() acc = MediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, config) @@ -75,33 +76,35 @@ async def test_media_player_set_state(hass: HomeAssistant, hk_driver, events) -> assert acc.chars[FEATURE_PLAY_STOP].value is False assert acc.chars[FEATURE_TOGGLE_MUTE].value is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_MEDIA_VOLUME_MUTED: True} + ) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is True assert acc.chars[FEATURE_TOGGLE_MUTE].value is True - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is False - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is True - hass.states.async_set(entity_id, STATE_STANDBY) + hass.states.async_set(entity_id, STATE_STANDBY, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is False - hass.states.async_set(entity_id, STATE_PLAYING) + hass.states.async_set(entity_id, STATE_PLAYING, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_PLAY_PAUSE].value is True assert acc.chars[FEATURE_PLAY_STOP].value is True - hass.states.async_set(entity_id, STATE_PAUSED) + hass.states.async_set(entity_id, STATE_PAUSED, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_PLAY_PAUSE].value is False - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set(entity_id, STATE_IDLE, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_PLAY_STOP].value is False @@ -180,15 +183,16 @@ async def test_media_player_television( # Supports 'select_source', 'volume_step', 'turn_on', 'turn_off', # 'volume_mute', 'volume_set', 'pause' + base_attrs = { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE_LIST: ["HDMI 1", "HDMI 2", "HDMI 3", "HDMI 4"], + } hass.states.async_set( entity_id, None, - { - ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - ATTR_SUPPORTED_FEATURES: 3469, - ATTR_MEDIA_VOLUME_MUTED: False, - ATTR_INPUT_SOURCE_LIST: ["HDMI 1", "HDMI 2", "HDMI 3", "HDMI 4"], - }, + base_attrs, ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) @@ -203,32 +207,40 @@ async def test_media_player_television( assert acc.char_input_source.value == 0 assert acc.char_mute.value is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_MEDIA_VOLUME_MUTED: True} + ) await hass.async_block_till_done() assert acc.char_active.value == 1 assert acc.char_mute.value is True - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_STANDBY) + hass.states.async_set(entity_id, STATE_STANDBY, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 2"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 2"} + ) await hass.async_block_till_done() assert acc.char_input_source.value == 1 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 3"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 3"} + ) await hass.async_block_till_done() assert acc.char_input_source.value == 2 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 5"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 5"} + ) await hass.async_block_till_done() assert acc.char_input_source.value == 0 assert caplog.records[-2].levelname == "DEBUG" @@ -358,12 +370,15 @@ async def test_media_player_television_basic( ) -> None: """Test if basic television accessory and HA are updated accordingly.""" entity_id = "media_player.television" - + base_attrs = { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 384, + } # Supports turn_on', 'turn_off' hass.states.async_set( entity_id, None, - {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, ATTR_SUPPORTED_FEATURES: 384}, + base_attrs, ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) @@ -374,15 +389,19 @@ async def test_media_player_television_basic( assert acc.chars_speaker == [] assert acc.support_select_source is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_MEDIA_VOLUME_MUTED: True} + ) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 3"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 3"} + ) await hass.async_block_till_done() assert acc.char_active.value == 1 diff --git a/tests/components/homekit/test_type_remote.py b/tests/components/homekit/test_type_remote.py index 0c0a2266eb1..2e7a5174701 100644 --- a/tests/components/homekit/test_type_remote.py +++ b/tests/components/homekit/test_type_remote.py @@ -1,13 +1,14 @@ """Test different accessory types: Remotes.""" +from unittest.mock import patch + import pytest +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, - DOMAIN as HOMEKIT_DOMAIN, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, KEY_ARROW_RIGHT, - SERVICE_HOMEKIT_RESET_ACCESSORY, ) from homeassistant.components.homekit.type_remotes import ActivityRemote from homeassistant.components.remote import ( @@ -30,18 +31,19 @@ from tests.common import async_mock_service async def test_activity_remote( - hass: HomeAssistant, hk_driver, events, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, hk_driver: HomeDriver, events, caplog: pytest.LogCaptureFixture ) -> None: """Test if remote accessory and HA are updated accordingly.""" entity_id = "remote.harmony" + base_attrs = { + ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, + ATTR_CURRENT_ACTIVITY: "Apple TV", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], + } hass.states.async_set( entity_id, None, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Apple TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + base_attrs, ) await hass.async_block_till_done() acc = ActivityRemote(hass, hk_driver, "ActivityRemote", entity_id, 2, None) @@ -58,47 +60,31 @@ async def test_activity_remote( hass.states.async_set( entity_id, STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Apple TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + base_attrs, ) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_STANDBY) + hass.states.async_set(entity_id, STATE_STANDBY, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + entity_id, STATE_ON, {**base_attrs, ATTR_CURRENT_ACTIVITY: "TV"} ) await hass.async_block_till_done() assert acc.char_input_source.value == 0 hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Apple TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + entity_id, STATE_ON, {**base_attrs, ATTR_CURRENT_ACTIVITY: "Apple TV"} ) await hass.async_block_till_done() assert acc.char_input_source.value == 1 @@ -154,21 +140,19 @@ async def test_activity_remote( assert len(events) == 1 assert events[0].data[ATTR_KEY_NAME] == KEY_ARROW_RIGHT - call_reset_accessory = async_mock_service( - hass, HOMEKIT_DOMAIN, SERVICE_HOMEKIT_RESET_ACCESSORY - ) - # A wild source appears - The accessory should rebuild itself - hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Amazon TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "Amazon TV"], - }, - ) - await hass.async_block_till_done() - assert call_reset_accessory[0].data[ATTR_ENTITY_ID] == entity_id + # A wild source appears - The accessory should reload itself + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, + STATE_ON, + { + **base_attrs, + ATTR_CURRENT_ACTIVITY: "Amazon TV", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "Amazon TV"], + }, + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_activity_remote_bad_names( diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index c48ebb86ce3..d2f0d87c507 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -1,4 +1,6 @@ """Test different accessory types: Sensors.""" +from unittest.mock import patch + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homekit import get_accessory from homeassistant.components.homekit.const import ( @@ -71,11 +73,13 @@ async def test_temperature(hass: HomeAssistant, hk_driver) -> None: await hass.async_block_till_done() assert acc.char_temp.value == 0 - hass.states.async_set( - entity_id, "75.2", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT} - ) - await hass.async_block_till_done() - assert acc.char_temp.value == 24 + # The UOM changes, the accessory should reload itself + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, "75.2", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT} + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_humidity(hass: HomeAssistant, hk_driver) -> None: diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index da51efb43f2..1c3fb0914f3 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -79,21 +79,22 @@ from tests.common import async_mock_service async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + } hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -105,7 +106,9 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: assert acc.aid == 1 assert acc.category == 9 # Thermostat - assert acc.get_temperature_range() == (7.0, 35.0) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (7.0, 35.0) assert acc.char_current_heat_cool.value == 0 assert acc.char_target_heat_cool.value == 0 assert acc.char_current_temp.value == 21.0 @@ -124,17 +127,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT, { + **base_attrs, ATTR_TEMPERATURE: 22.2, ATTR_CURRENT_TEMPERATURE: 17.8, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -148,17 +144,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 23.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -172,17 +161,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.FAN_ONLY, { + **base_attrs, ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 25.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -196,6 +178,7 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 19.0, ATTR_HVAC_ACTION: HVACAction.IDLE, @@ -211,7 +194,7 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, HVACMode.OFF, - {ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0}, + {**base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0}, ) await hass.async_block_till_done() assert acc.char_target_temp.value == 22.0 @@ -224,17 +207,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.AUTO, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -248,17 +224,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 25.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -272,17 +241,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.AUTO, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -296,17 +258,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.FAN_ONLY, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: HVACAction.FAN, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -320,7 +275,7 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.DRY, { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: HVACAction.DRYING, @@ -419,23 +374,23 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -458,18 +413,11 @@ async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 22.0, ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -484,18 +432,11 @@ async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 24.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -510,18 +451,11 @@ async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.AUTO, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -575,23 +509,23 @@ async def test_thermostat_mode_and_temp_change( ) -> None: """Test if accessory where the mode and temp change in the same call.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -614,18 +548,11 @@ async def test_thermostat_mode_and_temp_change( entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -688,9 +615,9 @@ async def test_thermostat_mode_and_temp_change( async def test_thermostat_humidity(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly with humidity.""" entity_id = "climate.test" - + base_attrs = {ATTR_SUPPORTED_FEATURES: 4} # support_auto = True - hass.states.async_set(entity_id, HVACMode.OFF, {ATTR_SUPPORTED_FEATURES: 4}) + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) @@ -704,14 +631,18 @@ async def test_thermostat_humidity(hass: HomeAssistant, hk_driver, events) -> No assert acc.char_target_humidity.properties[PROP_MIN_VALUE] == DEFAULT_MIN_HUMIDITY hass.states.async_set( - entity_id, HVACMode.HEAT_COOL, {ATTR_HUMIDITY: 65, ATTR_CURRENT_HUMIDITY: 40} + entity_id, + HVACMode.HEAT_COOL, + {**base_attrs, ATTR_HUMIDITY: 65, ATTR_CURRENT_HUMIDITY: 40}, ) await hass.async_block_till_done() assert acc.char_current_humidity.value == 40 assert acc.char_target_humidity.value == 65 hass.states.async_set( - entity_id, HVACMode.COOL, {ATTR_HUMIDITY: 35, ATTR_CURRENT_HUMIDITY: 70} + entity_id, + HVACMode.COOL, + {**base_attrs, ATTR_HUMIDITY: 35, ATTR_CURRENT_HUMIDITY: 70}, ) await hass.async_block_till_done() assert acc.char_current_humidity.value == 70 @@ -772,24 +703,24 @@ async def test_thermostat_humidity_with_target_humidity( async def test_thermostat_power_state(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: 4096, + ATTR_TEMPERATURE: 23.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_HVAC_MODES: [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.AUTO, + HVACMode.HEAT, + HVACMode.OFF, + ], + } # SUPPORT_ON_OFF = True hass.states.async_set( entity_id, HVACMode.HEAT, - { - ATTR_SUPPORTED_FEATURES: 4096, - ATTR_TEMPERATURE: 23.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.OFF, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -805,16 +736,10 @@ async def test_thermostat_power_state(hass: HomeAssistant, hk_driver, events) -> entity_id, HVACMode.OFF, { + **base_attrs, ATTR_TEMPERATURE: 23.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.OFF, - ], }, ) await hass.async_block_till_done() @@ -825,16 +750,10 @@ async def test_thermostat_power_state(hass: HomeAssistant, hk_driver, events) -> entity_id, HVACMode.OFF, { + **base_attrs, ATTR_TEMPERATURE: 23.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.OFF, - ], }, ) await hass.async_block_till_done() @@ -924,7 +843,9 @@ async def test_thermostat_fahrenheit(hass: HomeAssistant, hk_driver, events) -> }, ) await hass.async_block_till_done() - assert acc.get_temperature_range() == (7.0, 35.0) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (7.0, 35.0) assert acc.char_heating_thresh_temp.value == 20.1 assert acc.char_cooling_thresh_temp.value == 24.0 assert acc.char_current_temp.value == 23.0 @@ -1012,14 +933,18 @@ async def test_thermostat_get_temperature_range(hass: HomeAssistant, hk_driver) entity_id, HVACMode.OFF, {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25} ) await hass.async_block_till_done() - assert acc.get_temperature_range() == (20, 25) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (20, 25) acc._unit = UnitOfTemperature.FAHRENHEIT hass.states.async_set( entity_id, HVACMode.OFF, {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70} ) await hass.async_block_till_done() - assert acc.get_temperature_range() == (15.5, 21.0) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (15.5, 21.0) async def test_thermostat_temperature_step_whole( @@ -1065,9 +990,14 @@ async def test_thermostat_restore(hass: HomeAssistant, hk_driver, events) -> Non hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = Thermostat(hass, hk_driver, "Climate", "climate.simple", 2, None) + entity_id = "climate.simple" + hass.states.async_set(entity_id, HVACMode.OFF) + + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 2, None) assert acc.category == 9 - assert acc.get_temperature_range() == (7, 35) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (7, 35) assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == { "cool", "heat", @@ -1075,9 +1005,13 @@ async def test_thermostat_restore(hass: HomeAssistant, hk_driver, events) -> Non "off", } - acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 3, None) + entity_id = "climate.all_info_set" + state = hass.states.get(entity_id) + assert state + + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 3, None) assert acc.category == 9 - assert acc.get_temperature_range() == (60.0, 70.0) + assert acc.get_temperature_range(state) == (60.0, 70.0) assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == { "heat_cool", "off", @@ -1566,12 +1500,15 @@ async def test_thermostat_without_target_temp_only_range( ) -> None: """Test a thermostat that only supports a range.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE}, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -1594,19 +1531,11 @@ async def test_thermostat_without_target_temp_only_range( entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 22.0, ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -1621,19 +1550,11 @@ async def test_thermostat_without_target_temp_only_range( entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 24.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -1648,19 +1569,11 @@ async def test_thermostat_without_target_temp_only_range( entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -1866,15 +1779,19 @@ async def test_water_heater_get_temperature_range( hass.states.async_set( entity_id, HVACMode.HEAT, {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25} ) + state = hass.states.get(entity_id) + assert state await hass.async_block_till_done() - assert acc.get_temperature_range() == (20, 25) + assert acc.get_temperature_range(state) == (20, 25) acc._unit = UnitOfTemperature.FAHRENHEIT hass.states.async_set( entity_id, HVACMode.OFF, {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70} ) + state = hass.states.get(entity_id) + assert state await hass.async_block_till_done() - assert acc.get_temperature_range() == (15.5, 21.0) + assert acc.get_temperature_range(state) == (15.5, 21.0) async def test_water_heater_restore(hass: HomeAssistant, hk_driver, events) -> None: @@ -1899,20 +1816,27 @@ async def test_water_heater_restore(hass: HomeAssistant, hk_driver, events) -> N hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = Thermostat(hass, hk_driver, "WaterHeater", "water_heater.simple", 2, None) + entity_id = "water_heater.simple" + hass.states.async_set(entity_id, "off") + state = hass.states.get(entity_id) + assert state + + acc = Thermostat(hass, hk_driver, "WaterHeater", entity_id, 2, None) assert acc.category == 9 - assert acc.get_temperature_range() == (7, 35) + assert acc.get_temperature_range(state) == (7, 35) assert set(acc.char_current_heat_cool.properties["ValidValues"].keys()) == { "Cool", "Heat", "Off", } - acc = WaterHeater( - hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 3, None - ) + entity_id = "water_heater.all_info_set" + state = hass.states.get(entity_id) + assert state + + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 3, None) assert acc.category == 9 - assert acc.get_temperature_range() == (60.0, 70.0) + assert acc.get_temperature_range(state) == (60.0, 70.0) assert set(acc.char_current_heat_cool.properties["ValidValues"].keys()) == { "Cool", "Heat", @@ -1925,16 +1849,17 @@ async def test_thermostat_with_no_modes_when_we_first_see( ) -> None: """Test if a thermostat that is not ready when we first see it.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -1955,24 +1880,22 @@ async def test_thermostat_with_no_modes_when_we_first_see( assert acc.char_target_heat_cool.value == 0 - hass.states.async_set( - entity_id, - HVACMode.HEAT_COOL, - { - ATTR_TARGET_TEMP_HIGH: 22.0, - ATTR_TARGET_TEMP_LOW: 20.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.OFF, HVACMode.AUTO], - }, - ) - await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 20.0 - assert acc.char_cooling_thresh_temp.value == 22.0 - assert acc.char_current_heat_cool.value == 1 - assert acc.char_target_heat_cool.value == 3 - assert acc.char_current_temp.value == 18.0 - assert acc.char_display_units.value == 0 + # Verify reload on modes changed out from under us + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, + HVACMode.HEAT_COOL, + { + **base_attrs, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.OFF, HVACMode.AUTO], + }, + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_thermostat_with_no_off_after_recheck( @@ -1981,15 +1904,16 @@ async def test_thermostat_with_no_off_after_recheck( """Test if a thermostat that is not ready when we first see it that actually does not have off.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.COOL, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -2010,24 +1934,22 @@ async def test_thermostat_with_no_off_after_recheck( assert acc.char_target_heat_cool.value == 2 - hass.states.async_set( - entity_id, - HVACMode.HEAT_COOL, - { - ATTR_TARGET_TEMP_HIGH: 22.0, - ATTR_TARGET_TEMP_LOW: 20.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], - }, - ) - await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 20.0 - assert acc.char_cooling_thresh_temp.value == 22.0 - assert acc.char_current_heat_cool.value == 1 - assert acc.char_target_heat_cool.value == 3 - assert acc.char_current_temp.value == 18.0 - assert acc.char_display_units.value == 0 + # Verify reload when modes change out from under us + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, + HVACMode.HEAT_COOL, + { + **base_attrs, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], + }, + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_thermostat_with_temp_clamps( @@ -2035,17 +1957,17 @@ async def test_thermostat_with_temp_clamps( ) -> None: """Test that tempatures are clamped to valid values to prevent homekit crash.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], + ATTR_MAX_TEMP: 50, + ATTR_MIN_TEMP: 100, + } hass.states.async_set( entity_id, HVACMode.COOL, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [], - ATTR_MAX_TEMP: 50, - ATTR_MIN_TEMP: 100, - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -2064,17 +1986,17 @@ async def test_thermostat_with_temp_clamps( assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 - assert acc.char_target_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 3 hass.states.async_set( entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 822.0, ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 9918.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], }, ) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index 0374f3f1e94..84631646a6c 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -71,3 +71,4 @@ async def test_programmable_switch_button_fires_on_trigger( char = acc.get_characteristic(call.args[0]["aid"], call.args[0]["iid"]) assert char.display_name == CHAR_PROGRAMMABLE_SWITCH_EVENT await acc.stop() + await hass.async_block_till_done() diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 0046f90b284..60ee2a4d8e8 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -14,8 +14,6 @@ from homeassistant.components.homekit.const import ( DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, - HOMEKIT_PAIRING_QR, - HOMEKIT_PAIRING_QR_SECRET, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -23,6 +21,7 @@ from homeassistant.components.homekit.const import ( TYPE_SWITCH, TYPE_VALVE, ) +from homeassistant.components.homekit.models import HomeKitEntryData from homeassistant.components.homekit.util import ( accessory_friendly_name, async_dismiss_setup_message, @@ -251,8 +250,9 @@ async def test_async_show_setup_msg( hass, entry.entry_id, "bridge_name", pincode, "X-HM://0" ) await hass.async_block_till_done() - assert hass.data[DOMAIN][entry.entry_id][HOMEKIT_PAIRING_QR_SECRET] - assert hass.data[DOMAIN][entry.entry_id][HOMEKIT_PAIRING_QR] + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry.entry_id] + assert entry_data.pairing_qr_secret + assert entry_data.pairing_qr assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][1][3] == entry.entry_id diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 2b532769220..a5219fe7018 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -8,6 +8,7 @@ import os from typing import Any, Final from unittest import mock +from aiohomekit.controller.abstract import AbstractDescription, AbstractPairing from aiohomekit.hkjson import loads as hkloads from aiohomekit.model import ( Accessories, @@ -16,7 +17,6 @@ from aiohomekit.model import ( mixin as model_mixin, ) from aiohomekit.testing import FakeController, FakePairing -from aiohomekit.zeroconf import HomeKitService from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.homekit_controller.const import ( @@ -25,6 +25,7 @@ from homeassistant.components.homekit_controller.const import ( DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, IDENTIFIER_ACCESSORY_ID, + SUBSCRIBE_COOLDOWN, ) from homeassistant.components.homekit_controller.utils import async_get_controller from homeassistant.config_entries import ConfigEntry @@ -154,6 +155,13 @@ class Helper: assert state is not None return state + async def async_set_aid_iid_status( + self, aid_iid_status: list[tuple[int, int, int]] + ) -> None: + """Set the status of a set of aid/iid pairs.""" + self.pairing.testing.set_aid_iid_status(aid_iid_status) + await self.hass.async_block_till_done() + @callback def async_assert_service_values( self, service: str, characteristics: dict[str, Any] @@ -180,7 +188,7 @@ async def time_changed(hass, seconds): await hass.async_block_till_done() -async def setup_accessories_from_file(hass, path): +async def setup_accessories_from_file(hass: HomeAssistant, path: str) -> Accessories: """Load an collection of accessory defs from JSON data.""" accessories_fixture = await hass.async_add_executor_job( load_fixture, os.path.join("homekit_controller", path) @@ -237,40 +245,36 @@ async def setup_test_accessories_with_controller( config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) + await time_changed(hass, SUBSCRIBE_COOLDOWN) await hass.async_block_till_done() return config_entry, pairing -async def device_config_changed(hass, accessories): +async def device_config_changed(hass: HomeAssistant, accessories: Accessories): """Discover new devices added to Home Assistant at runtime.""" # Update the accessories our FakePairing knows about controller = hass.data[CONTROLLER] - pairing = controller.pairings["00:00:00:00:00:00"] + pairing: AbstractPairing = controller.pairings["00:00:00:00:00:00"] accessories_obj = Accessories() for accessory in accessories: accessories_obj.add_accessory(accessory) - pairing._accessories_state = AccessoriesState( - accessories_obj, pairing.config_num + 1 - ) + + new_config_num = pairing.config_num + 1 pairing._async_description_update( - HomeKitService( - name="TestDevice.local", + AbstractDescription( + name="testdevice.local.", id="00:00:00:00:00:00", - model="", - config_num=2, - state_num=3, - feature_flags=0, status_flags=0, + config_num=new_config_num, category=1, - protocol_version="1.0", - type="_hap._tcp.local.", - address="127.0.0.1", - addresses=["127.0.0.1"], - port=8080, ) ) + # Set the accessories state only after calling + # _async_description_update, otherwise the config_num will be + # overwritten + pairing._accessories_state = AccessoriesState(accessories_obj, new_config_num) # Wait for services to reconfigure await hass.async_block_till_done() diff --git a/tests/components/homekit_controller/fixtures/ecobee3_service_removed.json b/tests/components/homekit_controller/fixtures/ecobee3_service_removed.json new file mode 100644 index 00000000000..ba26866939c --- /dev/null +++ b/tests/components/homekit_controller/fixtures/ecobee3_service_removed.json @@ -0,0 +1,561 @@ +[ + { + "aid": 1, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "HomeW", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 3 + }, + { + "value": "123456789012", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 4 + }, + { + "value": "ecobee3", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 5 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 6 + }, + { + "value": "4.2.394", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "value": 0, + "perms": ["pr", "ev"], + "type": "A6", + "format": "uint32", + "iid": 9 + } + ], + "iid": 1 + }, + { + "type": "A2", + "characteristics": [ + { + "value": "1.1.0", + "perms": ["pr"], + "maxLen": 64, + "type": "37", + "format": "string", + "iid": 31 + } + ], + "iid": 30 + }, + { + "primary": true, + "type": "4A", + "characteristics": [ + { + "value": 1, + "maxValue": 2, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "F", + "minValue": 0, + "format": "uint8", + "iid": 17 + }, + { + "value": 1, + "maxValue": 3, + "minStep": 1, + "perms": ["pr", "pw", "ev"], + "type": "33", + "minValue": 0, + "format": "uint8", + "iid": 18 + }, + { + "value": 21.8, + "maxValue": 100, + "minStep": 0.1, + "perms": ["pr", "ev"], + "unit": "celsius", + "type": "11", + "minValue": 0, + "format": "float", + "iid": 19 + }, + { + "value": 22.2, + "maxValue": 33.3, + "minStep": 0.1, + "perms": ["pr", "pw", "ev"], + "unit": "celsius", + "type": "35", + "minValue": 7.2, + "format": "float", + "iid": 20 + }, + { + "value": 1, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "pw", "ev"], + "type": "36", + "minValue": 0, + "format": "uint8", + "iid": 21 + }, + { + "value": 24.4, + "maxValue": 33.3, + "minStep": 0.1, + "perms": ["pr", "pw", "ev"], + "unit": "celsius", + "type": "D", + "minValue": 18.3, + "format": "float", + "iid": 22 + }, + { + "value": 22.2, + "maxValue": 26.1, + "minStep": 0.1, + "perms": ["pr", "pw", "ev"], + "unit": "celsius", + "type": "12", + "minValue": 7.2, + "format": "float", + "iid": 23 + }, + { + "value": 34, + "maxValue": 100, + "minStep": 1, + "perms": ["pr", "ev"], + "unit": "percentage", + "type": "10", + "minValue": 0, + "format": "float", + "iid": 24 + }, + { + "value": 36, + "maxValue": 50, + "minStep": 1, + "perms": ["pr", "pw", "ev"], + "unit": "percentage", + "type": "34", + "minValue": 20, + "format": "float", + "iid": 25 + }, + { + "value": "HomeW", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 27 + } + ], + "iid": 16 + } + ] + }, + { + "aid": 2, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "Kitchen", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2049 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 2050 + }, + { + "value": "AB1C", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 2051 + }, + { + "value": "REMOTE SENSOR", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 2052 + }, + { + "value": "1.0.0", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 2053 + } + ], + "iid": 1 + }, + { + "type": "8A", + "characteristics": [ + { + "value": 21.5, + "maxValue": 100, + "minStep": 0.1, + "perms": ["pr", "ev"], + "unit": "celsius", + "type": "11", + "minValue": 0, + "format": "float", + "iid": 2064 + }, + { + "value": "Kitchen", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2067 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 2066 + }, + { + "value": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "minValue": 0, + "format": "uint8", + "iid": 2065 + } + ], + "iid": 55 + }, + { + "type": "85", + "characteristics": [ + { + "value": false, + "perms": ["pr", "ev"], + "type": "22", + "format": "bool", + "iid": 2060 + }, + { + "value": "Kitchen", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2063 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 2062 + }, + { + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "value": 0, + "format": "uint8", + "iid": 2061 + }, + { + "minValue": -1, + "maxValue": 86400, + "perms": ["pr", "ev"], + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "value": 3620, + "format": "int", + "iid": 2059 + } + ], + "iid": 56 + } + ] + }, + { + "aid": 3, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "Porch", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 3073 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 3074 + }, + { + "value": "AB2C", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 3075 + }, + { + "value": "REMOTE SENSOR", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 3076 + }, + { + "value": "1.0.0", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 3077 + } + ], + "iid": 1 + }, + { + "type": "8A", + "characteristics": [ + { + "value": 21, + "maxValue": 100, + "minStep": 0.1, + "perms": ["pr", "ev"], + "unit": "celsius", + "type": "11", + "minValue": 0, + "format": "float", + "iid": 3088 + }, + { + "value": "Porch", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 3091 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 3090 + }, + { + "value": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "minValue": 0, + "format": "uint8", + "iid": 3089 + } + ], + "iid": 55 + }, + { + "type": "85", + "characteristics": [ + { + "value": false, + "perms": ["pr", "ev"], + "type": "22", + "format": "bool", + "iid": 3084 + }, + { + "value": "Porch", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 3087 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 3086 + }, + { + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "value": 0, + "format": "uint8", + "iid": 3085 + }, + { + "minValue": -1, + "maxValue": 86400, + "perms": ["pr", "ev"], + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "value": 5766, + "format": "int", + "iid": 3083 + } + ], + "iid": 56 + } + ] + }, + { + "aid": 4, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "Basement", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 4097 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 4098 + }, + { + "value": "AB3C", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 4099 + }, + { + "value": "REMOTE SENSOR", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 4100 + }, + { + "value": "1.0.0", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 4101 + } + ], + "iid": 1 + }, + { + "type": "85", + "characteristics": [ + { + "value": false, + "perms": ["pr", "ev"], + "type": "22", + "format": "bool", + "iid": 4108 + }, + { + "value": "Basement", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 4111 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 4110 + }, + { + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "value": 0, + "format": "uint8", + "iid": 4109 + }, + { + "minValue": -1, + "maxValue": 86400, + "perms": ["pr", "ev"], + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "value": 5472, + "format": "int", + "iid": 4107 + } + ], + "iid": 56 + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json new file mode 100644 index 00000000000..e508a3523c4 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json @@ -0,0 +1,244 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Bridge" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Home Assistant Bridge" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "homekit.bridge" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + } + ] + }, + { + "aid": 1256851357, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Fan" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Living Room Fan" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "fan.living_room_fan" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "Active", + "format": "uint8", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "type": "000000B0-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationDirection", + "format": "int", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "type": "00000028-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationSpeed", + "format": "float", + "iid": 12, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": ["pr", "pw", "ev"], + "type": "00000029-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100 + } + ], + "iid": 8, + "stype": "fanv2", + "type": "000000B7-0000-1000-8000-0026BB765291" + } + ] + }, + { + "aid": 766313939, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Fan" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Ceiling Fan" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "fan.ceiling_fan" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "Active", + "format": "uint8", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "type": "000000B0-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationSpeed", + "format": "float", + "iid": 10, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": ["pr", "pw", "ev"], + "type": "00000029-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100 + } + ], + "iid": 8, + "stype": "fanv2", + "type": "000000B7-0000-1000-8000-0026BB765291" + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json new file mode 100644 index 00000000000..f7aaab11384 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json @@ -0,0 +1,166 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Bridge" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Home Assistant Bridge" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "homekit.bridge" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + } + ] + }, + { + "aid": 1256851357, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Fan" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Living Room Fan" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "fan.living_room_fan" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "Active", + "format": "uint8", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "type": "000000B0-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationDirection", + "format": "int", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "type": "00000028-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "SwingMode", + "format": "uint8", + "iid": 11, + "perms": ["pr", "pw", "ev"], + "type": "000000B6-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationSpeed", + "format": "float", + "iid": 12, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": ["pr", "pw", "ev"], + "type": "00000029-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100 + } + ], + "iid": 8, + "stype": "fanv2", + "type": "000000B7-0000-1000-8000-0026BB765291" + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 4c408f2887e..a0c6fd00ee6 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -24,6 +24,7 @@ 'model': 'AP2', 'name': 'Airversa AP2 1808', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.16', }), @@ -50,6 +51,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -85,6 +87,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Provision Preferred Thread Credentials', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_112_119', @@ -122,6 +125,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Air Quality', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2576_2579', @@ -161,6 +165,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Filter lifetime', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32896_32900', @@ -200,6 +205,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 PM2.5 Density', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2576_2580', @@ -247,6 +253,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Thread Capabilities', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thread_node_capabilities', 'unique_id': '00:00:00:00:00:00_1_112_115', @@ -301,6 +308,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Thread Status', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thread_status', 'unique_id': '00:00:00:00:00:00_1_112_117', @@ -346,6 +354,7 @@ 'original_icon': 'mdi:lock-open', 'original_name': 'Airversa AP2 1808 Lock Physical Controls', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832_32839', @@ -382,6 +391,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'Airversa AP2 1808 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832_32843', @@ -418,6 +428,7 @@ 'original_icon': 'mdi:power-sleep', 'original_name': 'Airversa AP2 1808 Sleep Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832_32842', @@ -461,6 +472,7 @@ 'model': 'T8010', 'name': 'eufy HomeBase2-0AAA', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '2.1.6', }), @@ -487,6 +499,7 @@ 'original_icon': None, 'original_name': 'eufy HomeBase2-0AAA Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -525,6 +538,7 @@ 'model': 'T8113', 'name': 'eufyCam2-0000', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.6.7', }), @@ -551,6 +565,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-0000 Motion Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_160', @@ -587,6 +602,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-0000 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_2', @@ -622,6 +638,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-0000', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4', @@ -660,6 +677,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'eufyCam2-0000 Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_101', @@ -699,6 +717,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'eufyCam2-0000 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_80_83', @@ -738,6 +757,7 @@ 'model': 'T8113', 'name': 'eufyCam2-000A', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.6.7', }), @@ -764,6 +784,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A Motion Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_160', @@ -800,6 +821,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2', @@ -835,6 +857,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2', @@ -873,6 +896,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'eufyCam2-000A Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_101', @@ -912,6 +936,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'eufyCam2-000A Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_80_83', @@ -951,6 +976,7 @@ 'model': 'T8113', 'name': 'eufyCam2-000A', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.6.7', }), @@ -977,6 +1003,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A Motion Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_160', @@ -1013,6 +1040,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_2', @@ -1048,6 +1076,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3', @@ -1086,6 +1115,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'eufyCam2-000A Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_101', @@ -1125,6 +1155,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'eufyCam2-000A Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_80_83', @@ -1168,6 +1199,7 @@ 'model': 'HE1-G01', 'name': 'Aqara-Hub-E1-00A0', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.3.0', }), @@ -1194,6 +1226,7 @@ 'original_icon': 'mdi:security', 'original_name': 'Aqara-Hub-E1-00A0 Security System', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -1234,6 +1267,7 @@ 'original_icon': None, 'original_name': 'Aqara-Hub-E1-00A0 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_65537', @@ -1274,6 +1308,7 @@ 'original_icon': 'mdi:volume-high', 'original_name': 'Aqara-Hub-E1-00A0 Volume', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17_1114116', @@ -1314,6 +1349,7 @@ 'original_icon': 'mdi:lock-open', 'original_name': 'Aqara-Hub-E1-00A0 Pairing Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17_1114117', @@ -1353,6 +1389,7 @@ 'model': 'AS006', 'name': 'Contact Sensor', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0', }), @@ -1379,6 +1416,7 @@ 'original_icon': None, 'original_name': 'Contact Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_33_4', @@ -1415,6 +1453,7 @@ 'original_icon': None, 'original_name': 'Contact Sensor Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_33_1_65537', @@ -1452,6 +1491,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Contact Sensor Battery Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_33_5', @@ -1498,6 +1538,7 @@ 'model': 'ZHWA11LM', 'name': 'Aqara Hub-1563', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.4.7', }), @@ -1524,6 +1565,7 @@ 'original_icon': 'mdi:security', 'original_name': 'Aqara Hub-1563 Security System', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_66304', @@ -1564,6 +1606,7 @@ 'original_icon': None, 'original_name': 'Aqara Hub-1563 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -1603,6 +1646,7 @@ 'original_icon': None, 'original_name': 'Aqara Hub-1563 Lightbulb-1563', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_65792', @@ -1610,11 +1654,16 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Aqara Hub-1563 Lightbulb-1563', + 'hs_color': None, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.aqara_hub_1563_lightbulb_1563', 'state': 'off', @@ -1647,6 +1696,7 @@ 'original_icon': 'mdi:volume-high', 'original_name': 'Aqara Hub-1563 Volume', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_65536_65541', @@ -1687,6 +1737,7 @@ 'original_icon': 'mdi:lock-open', 'original_name': 'Aqara Hub-1563 Pairing Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_65536_65538', @@ -1730,6 +1781,7 @@ 'model': 'AR004', 'name': 'Programmable Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '9', }), @@ -1756,6 +1808,7 @@ 'original_icon': None, 'original_name': 'Programmable Switch Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_65537', @@ -1793,6 +1846,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Programmable Switch Battery Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_5', @@ -1839,6 +1893,7 @@ 'model': 'ABC1000', 'name': 'ArloBabyA0', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.10.931', }), @@ -1865,6 +1920,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Motion', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_500', @@ -1901,6 +1957,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -1936,6 +1993,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1', @@ -1976,6 +2034,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Nightlight', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1100', @@ -1983,11 +2042,16 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'ArloBabyA0 Nightlight', + 'hs_color': None, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.arlobabya0_nightlight', 'state': 'off', @@ -2017,6 +2081,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Air Quality', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_800_802', @@ -2056,6 +2121,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'ArloBabyA0 Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_700', @@ -2097,6 +2163,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_900', @@ -2137,6 +2204,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1000', @@ -2175,6 +2243,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'ArloBabyA0 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_300_302', @@ -2211,6 +2280,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'ArloBabyA0 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_400_402', @@ -2254,6 +2324,7 @@ 'model': 'CS-IWO', 'name': 'InWall Outlet-0394DE', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -2280,6 +2351,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -2317,6 +2389,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Current', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13_18', @@ -2357,6 +2430,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Current', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25_30', @@ -2397,6 +2471,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Energy kWh', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13_20', @@ -2437,6 +2512,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Energy kWh', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25_32', @@ -2477,6 +2553,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13_19', @@ -2517,6 +2594,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25_31', @@ -2555,6 +2633,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Outlet A', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13', @@ -2591,6 +2670,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Outlet B', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25', @@ -2634,6 +2714,7 @@ 'model': 'REMOTE SENSOR', 'name': 'Basement', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -2660,6 +2741,7 @@ 'original_icon': None, 'original_name': 'Basement', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_56', @@ -2696,6 +2778,7 @@ 'original_icon': None, 'original_name': 'Basement Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_4101', @@ -2733,6 +2816,7 @@ 'original_icon': None, 'original_name': 'Basement Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_55', @@ -2774,6 +2858,7 @@ 'model': 'ecobee3', 'name': 'HomeW', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.2.394', }), @@ -2800,6 +2885,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -2836,6 +2922,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -2872,6 +2959,7 @@ 'original_icon': None, 'original_name': 'HomeW Clear Hold', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -2907,6 +2995,7 @@ 'original_icon': None, 'original_name': 'HomeW Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -2953,6 +3042,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -3012,6 +3102,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -3057,6 +3148,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'HomeW Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -3099,6 +3191,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -3139,6 +3232,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -3180,6 +3274,7 @@ 'model': 'REMOTE SENSOR', 'name': 'Kitchen', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -3206,6 +3301,7 @@ 'original_icon': None, 'original_name': 'Kitchen', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_56', @@ -3242,6 +3338,7 @@ 'original_icon': None, 'original_name': 'Kitchen Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2053', @@ -3279,6 +3376,7 @@ 'original_icon': None, 'original_name': 'Kitchen Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_55', @@ -3320,6 +3418,7 @@ 'model': 'REMOTE SENSOR', 'name': 'Porch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -3346,6 +3445,7 @@ 'original_icon': None, 'original_name': 'Porch', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_56', @@ -3382,6 +3482,7 @@ 'original_icon': None, 'original_name': 'Porch Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_3077', @@ -3419,6 +3520,7 @@ 'original_icon': None, 'original_name': 'Porch Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_55', @@ -3464,6 +3566,7 @@ 'model': 'ecobee3', 'name': 'HomeW', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.2.394', }), @@ -3490,6 +3593,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -3526,6 +3630,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -3562,6 +3667,7 @@ 'original_icon': None, 'original_name': 'HomeW Clear Hold', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -3597,6 +3703,7 @@ 'original_icon': None, 'original_name': 'HomeW Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -3643,6 +3750,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -3702,6 +3810,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -3747,6 +3856,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'HomeW Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -3789,6 +3899,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -3829,6 +3940,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -3849,6 +3961,660 @@ }), ]) # --- +# name: test_snapshots[ecobee3_service_removed] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Basement', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement', + }), + 'entity_id': 'binary_sensor.basement', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_4101', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3', + 'name': 'HomeW', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.2.394', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.homew_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Identify', + }), + 'entity_id': 'button.homew_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 21.8, + 'friendly_name': 'HomeW', + 'humidity': 36, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.2, + }), + 'entity_id': 'climate.homew', + 'state': 'heat', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homew_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'HomeW Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.homew_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'HomeW Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.homew_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'HomeW Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.homew_current_temperature', + 'state': '21.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Kitchen', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kitchen', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Kitchen', + }), + 'entity_id': 'binary_sensor.kitchen', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2053', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Kitchen Identify', + }), + 'entity_id': 'button.kitchen_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Kitchen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.kitchen_temperature', + 'state': '21.5', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Porch', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.porch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Porch', + }), + 'entity_id': 'binary_sensor.porch', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.porch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Porch Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_3077', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Porch Identify', + }), + 'entity_id': 'button.porch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.porch_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Porch Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.porch_temperature', + 'state': '21', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[ecobee_501] list([ dict({ @@ -3874,6 +4640,7 @@ 'model': 'ECB501', 'name': 'My ecobee', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.7.340214', }), @@ -3900,6 +4667,7 @@ 'original_icon': None, 'original_name': 'My ecobee Motion', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -3936,6 +4704,7 @@ 'original_icon': None, 'original_name': 'My ecobee Occupancy', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -3972,6 +4741,7 @@ 'original_icon': None, 'original_name': 'My ecobee Clear Hold', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -4007,6 +4777,7 @@ 'original_icon': None, 'original_name': 'My ecobee Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -4057,6 +4828,7 @@ 'original_icon': None, 'original_name': 'My ecobee', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -4121,6 +4893,7 @@ 'original_icon': None, 'original_name': 'My ecobee Current Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -4166,6 +4939,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'My ecobee Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -4208,6 +4982,7 @@ 'original_icon': None, 'original_name': 'My ecobee Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -4248,6 +5023,7 @@ 'original_icon': None, 'original_name': 'My ecobee Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -4293,6 +5069,7 @@ 'model': 'ecobee Switch+', 'name': 'Master Fan', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.5.130201', }), @@ -4319,6 +5096,7 @@ 'original_icon': None, 'original_name': 'Master Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -4355,6 +5133,7 @@ 'original_icon': None, 'original_name': 'Master Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -4391,6 +5170,7 @@ 'original_icon': None, 'original_name': 'Master Fan Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -4428,6 +5208,7 @@ 'original_icon': None, 'original_name': 'Master Fan Light Level', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_27', @@ -4468,6 +5249,7 @@ 'original_icon': None, 'original_name': 'Master Fan Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_55', @@ -4506,6 +5288,7 @@ 'original_icon': None, 'original_name': 'Master Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -4548,6 +5331,7 @@ 'model': 'Eve Degree 00AAA0000', 'name': 'Eve Degree AA11', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.2.8', }), @@ -4574,6 +5358,7 @@ 'original_icon': None, 'original_name': 'Eve Degree AA11 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_3', @@ -4614,6 +5399,7 @@ 'original_icon': 'mdi:elevation-rise', 'original_name': 'Eve Degree AA11 Elevation', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_33', @@ -4659,6 +5445,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'Eve Degree AA11 Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_22_25', @@ -4701,6 +5488,7 @@ 'original_icon': None, 'original_name': 'Eve Degree AA11 Air Pressure', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_32', @@ -4741,6 +5529,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Eve Degree AA11 Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17', @@ -4782,6 +5571,7 @@ 'original_icon': None, 'original_name': 'Eve Degree AA11 Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_27', @@ -4822,6 +5612,7 @@ 'original_icon': None, 'original_name': 'Eve Degree AA11 Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_22', @@ -4867,6 +5658,7 @@ 'model': 'Eve Energy 20EAO8601', 'name': 'Eve Energy 50FF', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.2.9', }), @@ -4893,6 +5685,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_3', @@ -4930,6 +5723,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Amps', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_33', @@ -4970,6 +5764,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Energy kWh', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_35', @@ -5010,6 +5805,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_34', @@ -5050,6 +5846,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Volts', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_32', @@ -5088,6 +5885,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28', @@ -5124,6 +5922,7 @@ 'original_icon': 'mdi:lock-open', 'original_name': 'Eve Energy 50FF Lock Physical Controls', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_36', @@ -5167,6 +5966,7 @@ 'model': 'RavenSystem HAA', 'name': 'HAA-C718B3', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '5.0.18', }), @@ -5193,6 +5993,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -5228,6 +6029,7 @@ 'original_icon': 'mdi:cog', 'original_name': 'HAA-C718B3 Setup', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1010_1012', @@ -5264,6 +6066,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3 Update', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1010_1011', @@ -5302,6 +6105,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -5345,6 +6149,7 @@ 'model': 'RavenSystem HAA', 'name': 'HAA-C718B3', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '5.0.18', }), @@ -5371,6 +6176,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_7', @@ -5406,6 +6212,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_8', @@ -5423,7 +6230,7 @@ }), ]) # --- -# name: test_snapshots[home_assistant_bridge_fan] +# name: test_snapshots[home_assistant_bridge_basic_fan] list([ dict({ 'device': dict({ @@ -5448,6 +6255,7 @@ 'model': 'Fan', 'name': 'Ceiling Fan', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -5474,6 +6282,7 @@ 'original_icon': None, 'original_name': 'Ceiling Fan Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_766313939_1_2', @@ -5511,6 +6320,7 @@ 'original_icon': None, 'original_name': 'Ceiling Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_766313939_8', @@ -5554,6 +6364,7 @@ 'model': 'Bridge', 'name': 'Home Assistant Bridge', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -5580,6 +6391,7 @@ 'original_icon': None, 'original_name': 'Home Assistant Bridge Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -5618,6 +6430,7 @@ 'model': 'Fan', 'name': 'Living Room Fan', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -5644,6 +6457,7 @@ 'original_icon': None, 'original_name': 'Living Room Fan Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_1_2', @@ -5681,6 +6495,477 @@ 'original_icon': None, 'original_name': 'Living Room Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:766313939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Ceiling Fan', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ceiling_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan Identify', + }), + 'entity_id': 'button.ceiling_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan', + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.ceiling_fan', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'oscillating': False, + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_fan_one_removed] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_8', @@ -5730,6 +7015,7 @@ 'model': 'Daikin-fwec3a-esp32-homekit-bridge', 'name': 'Air Conditioner', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -5756,6 +7042,7 @@ 'original_icon': None, 'original_name': 'Air Conditioner Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -5807,6 +7094,7 @@ 'original_icon': None, 'original_name': 'Air Conditioner SlaveID 1', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9', @@ -5864,6 +7152,7 @@ 'original_icon': None, 'original_name': 'Air Conditioner Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9_11', @@ -5909,6 +7198,7 @@ 'model': 'LTW012', 'name': 'Hue ambiance candle', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -5935,6 +7225,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276914_1_6', @@ -5978,6 +7269,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276914_2816', @@ -5985,15 +7277,22 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Hue ambiance candle', + 'hs_color': None, 'max_color_temp_kelvin': 6535, 'max_mireds': 454, 'min_color_temp_kelvin': 2202, 'min_mireds': 153, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.hue_ambiance_candle_4', 'state': 'off', @@ -6024,6 +7323,7 @@ 'model': 'LTW012', 'name': 'Hue ambiance candle', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -6050,6 +7350,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276939_1_6', @@ -6093,6 +7394,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276939_2816', @@ -6100,15 +7402,22 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Hue ambiance candle', + 'hs_color': None, 'max_color_temp_kelvin': 6535, 'max_mireds': 454, 'min_color_temp_kelvin': 2202, 'min_mireds': 153, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.hue_ambiance_candle_3', 'state': 'off', @@ -6139,6 +7448,7 @@ 'model': 'LTW012', 'name': 'Hue ambiance candle', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -6165,6 +7475,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403113447_1_6', @@ -6208,6 +7519,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403113447_2816', @@ -6215,15 +7527,22 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Hue ambiance candle', + 'hs_color': None, 'max_color_temp_kelvin': 6535, 'max_mireds': 454, 'min_color_temp_kelvin': 2202, 'min_mireds': 153, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.hue_ambiance_candle_2', 'state': 'off', @@ -6254,6 +7573,7 @@ 'model': 'LTW012', 'name': 'Hue ambiance candle', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -6280,6 +7600,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403233419_1_6', @@ -6323,6 +7644,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403233419_2816', @@ -6330,15 +7652,22 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Hue ambiance candle', + 'hs_color': None, 'max_color_temp_kelvin': 6535, 'max_mireds': 454, 'min_color_temp_kelvin': 2202, 'min_mireds': 153, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.hue_ambiance_candle', 'state': 'off', @@ -6369,6 +7698,7 @@ 'model': 'LTW013', 'name': 'Hue ambiance spot', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -6395,6 +7725,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance spot Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412411853_1_6', @@ -6438,6 +7769,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance spot', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412411853_2816', @@ -6501,6 +7833,7 @@ 'model': 'LTW013', 'name': 'Hue ambiance spot', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -6527,6 +7860,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance spot Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412413293_1_6', @@ -6570,6 +7904,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance spot', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412413293_2816', @@ -6633,6 +7968,7 @@ 'model': 'RWL021', 'name': 'Hue dimmer switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '45.1.17846', }), @@ -6659,6 +7995,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462389072572_1_22', @@ -6698,6 +8035,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch button 1', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410585088', @@ -6742,6 +8080,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch button 2', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410650624', @@ -6786,6 +8125,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch button 3', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410716160', @@ -6830,6 +8170,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch button 4', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410781696', @@ -6872,6 +8213,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Hue dimmer switch battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462389072572_644245094400', @@ -6914,6 +8256,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -6940,6 +8283,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378982941_1_6', @@ -6979,6 +8323,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378982941_2816', @@ -6986,6 +8331,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -7021,6 +8368,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7047,6 +8395,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378983942_1_6', @@ -7086,6 +8435,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378983942_2816', @@ -7093,6 +8443,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -7128,6 +8480,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7154,6 +8507,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379122122_1_6', @@ -7193,6 +8547,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379122122_2816', @@ -7200,6 +8555,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -7235,6 +8592,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7261,6 +8619,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379123707_1_6', @@ -7300,6 +8659,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379123707_2816', @@ -7307,6 +8667,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -7342,6 +8704,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7368,6 +8731,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114163_1_6', @@ -7407,6 +8771,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114163_2816', @@ -7414,6 +8779,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -7449,6 +8816,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7475,6 +8843,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114193_1_6', @@ -7514,6 +8883,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114193_2816', @@ -7521,6 +8891,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -7556,6 +8928,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7582,6 +8955,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462385996792_1_6', @@ -7621,6 +8995,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462385996792_2816', @@ -7628,6 +9003,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -7663,6 +9040,7 @@ 'model': 'BSB002', 'name': 'Philips hue - 482544', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.32.1932126170', }), @@ -7689,6 +9067,7 @@ 'original_icon': None, 'original_name': 'Philips hue - 482544 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -7731,6 +9110,7 @@ 'model': 'LS1', 'name': 'Koogeek-LS1-20833F', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '2.2.15', }), @@ -7757,6 +9137,7 @@ 'original_icon': None, 'original_name': 'Koogeek-LS1-20833F Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -7796,6 +9177,7 @@ 'original_icon': None, 'original_name': 'Koogeek-LS1-20833F Light Strip', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_7', @@ -7803,11 +9185,16 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'hs_color': None, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.koogeek_ls1_20833f_light_strip', 'state': 'off', @@ -7842,6 +9229,7 @@ 'model': 'P1EU', 'name': 'Koogeek-P1-A00AA0', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '2.3.7', }), @@ -7868,6 +9256,7 @@ 'original_icon': None, 'original_name': 'Koogeek-P1-A00AA0 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -7905,6 +9294,7 @@ 'original_icon': None, 'original_name': 'Koogeek-P1-A00AA0 Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_21_22', @@ -7943,6 +9333,7 @@ 'original_icon': None, 'original_name': 'Koogeek-P1-A00AA0 outlet', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_7', @@ -7986,6 +9377,7 @@ 'model': 'KH02CN', 'name': 'Koogeek-SW2-187A91', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.3', }), @@ -8012,6 +9404,7 @@ 'original_icon': None, 'original_name': 'Koogeek-SW2-187A91 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -8049,6 +9442,7 @@ 'original_icon': None, 'original_name': 'Koogeek-SW2-187A91 Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_14_18', @@ -8087,6 +9481,7 @@ 'original_icon': None, 'original_name': 'Koogeek-SW2-187A91 Switch 1', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -8122,6 +9517,7 @@ 'original_icon': None, 'original_name': 'Koogeek-SW2-187A91 Switch 2', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_11', @@ -8164,6 +9560,7 @@ 'model': 'E30 2B', 'name': 'Lennox', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.40.XX', }), @@ -8190,6 +9587,7 @@ 'original_icon': None, 'original_name': 'Lennox Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -8234,6 +9632,7 @@ 'original_icon': None, 'original_name': 'Lennox', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100', @@ -8289,6 +9688,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'Lennox Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_100_105', @@ -8331,6 +9731,7 @@ 'original_icon': None, 'original_name': 'Lennox Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100_107', @@ -8371,6 +9772,7 @@ 'original_icon': None, 'original_name': 'Lennox Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100_103', @@ -8416,6 +9818,7 @@ 'model': 'OLED55B9PUA', 'name': 'LG webOS TV AF80', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '04.71.04', }), @@ -8442,6 +9845,7 @@ 'original_icon': None, 'original_name': 'LG webOS TV AF80 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -8487,6 +9891,7 @@ 'original_icon': None, 'original_name': 'LG webOS TV AF80', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48', @@ -8534,6 +9939,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'LG webOS TV AF80 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_80_82', @@ -8577,6 +9983,7 @@ 'model': 'PD-FSQN-XX', 'name': 'Caséta® Wireless Fan Speed Control', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '001.005', }), @@ -8603,6 +10010,7 @@ 'original_icon': None, 'original_name': 'Caséta® Wireless Fan Speed Control Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_21474836482_1_85899345921', @@ -8640,6 +10048,7 @@ 'original_icon': None, 'original_name': 'Caséta® Wireless Fan Speed Control', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_21474836482_2', @@ -8683,6 +10092,7 @@ 'model': 'L-BDG2-WH', 'name': 'Smart Bridge 2', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '08.08', }), @@ -8709,6 +10119,7 @@ 'original_icon': None, 'original_name': 'Smart Bridge 2 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_85899345921', @@ -8751,6 +10162,7 @@ 'model': 'MSS425F', 'name': 'MSS425F-15cc', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.2.3', }), @@ -8777,6 +10189,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -8812,6 +10225,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Outlet-1', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_12', @@ -8847,6 +10261,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Outlet-2', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_15', @@ -8882,6 +10297,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Outlet-3', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_18', @@ -8917,6 +10333,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Outlet-4', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_21', @@ -8952,6 +10369,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc USB', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_24', @@ -8994,6 +10412,7 @@ 'model': 'MSS565', 'name': 'MSS565-28da', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.1.9', }), @@ -9020,6 +10439,7 @@ 'original_icon': None, 'original_name': 'MSS565-28da Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -9059,6 +10479,7 @@ 'original_icon': None, 'original_name': 'MSS565-28da Dimmer Switch', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_12', @@ -9107,6 +10528,7 @@ 'model': 'v1', 'name': 'Mysa-85dda9', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '2.8.1', }), @@ -9133,6 +10555,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -9177,6 +10600,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Thermostat', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20', @@ -9229,6 +10653,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Display', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_40', @@ -9236,6 +10661,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Mysa-85dda9 Display', 'supported_color_modes': list([ , @@ -9273,6 +10700,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'Mysa-85dda9 Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_20_26', @@ -9315,6 +10743,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20_27', @@ -9355,6 +10784,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20_25', @@ -9400,6 +10830,7 @@ 'model': 'NL55', 'name': 'Nanoleaf Strip 3B32', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.4.40', }), @@ -9426,6 +10857,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -9461,6 +10893,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Provision Preferred Thread Credentials', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_31_119', @@ -9505,6 +10938,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_19', @@ -9514,6 +10948,8 @@ 'attributes': dict({ 'brightness': 255.0, 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', 'hs_color': tuple( 30.0, @@ -9573,6 +11009,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Thread Capabilities', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thread_node_capabilities', 'unique_id': '00:00:00:00:00:00_1_31_115', @@ -9627,6 +11064,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Thread Status', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thread_status', 'unique_id': '00:00:00:00:00:00_1_31_117', @@ -9679,6 +11117,7 @@ 'model': 'Netatmo Doorbell', 'name': 'Netatmo-Doorbell-g738658', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '80.0.0', }), @@ -9705,6 +11144,7 @@ 'original_icon': None, 'original_name': 'Netatmo-Doorbell-g738658 Motion Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_10', @@ -9741,6 +11181,7 @@ 'original_icon': None, 'original_name': 'Netatmo-Doorbell-g738658 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -9776,6 +11217,7 @@ 'original_icon': None, 'original_name': 'Netatmo-Doorbell-g738658', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1', @@ -9818,6 +11260,7 @@ 'original_icon': None, 'original_name': 'Netatmo-Doorbell-g738658', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'doorbell', 'unique_id': '00:00:00:00:00:00_1_49', @@ -9860,6 +11303,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'Netatmo-Doorbell-g738658 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_51_52', @@ -9896,6 +11340,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'Netatmo-Doorbell-g738658 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8_9', @@ -9939,6 +11384,7 @@ 'model': 'Smart CO Alarm', 'name': 'Smart CO Alarm', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.3', }), @@ -9965,6 +11411,7 @@ 'original_icon': None, 'original_name': 'Smart CO Alarm Carbon Monoxide Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_22', @@ -10001,6 +11448,7 @@ 'original_icon': None, 'original_name': 'Smart CO Alarm Low Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_36', @@ -10037,6 +11485,7 @@ 'original_icon': None, 'original_name': 'Smart CO Alarm Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_7_3', @@ -10079,6 +11528,7 @@ 'model': 'Healthy Home Coach', 'name': 'Healthy Home Coach', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '59', }), @@ -10105,6 +11555,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -10142,6 +11593,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Air Quality', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_24_8', @@ -10181,6 +11633,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Carbon Dioxide sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_10', @@ -10221,6 +11674,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Humidity sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_14', @@ -10261,6 +11715,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Noise', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20_21', @@ -10301,6 +11756,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Temperature sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17', @@ -10346,6 +11802,7 @@ 'model': 'SPK5 Pro', 'name': 'RainMachine-00ce4a', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4', }), @@ -10372,6 +11829,7 @@ 'original_icon': None, 'original_name': 'RainMachine-00ce4a Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -10407,6 +11865,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_512', @@ -10446,6 +11905,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_768', @@ -10485,6 +11945,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1024', @@ -10524,6 +11985,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1280', @@ -10563,6 +12025,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1536', @@ -10602,6 +12065,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1792', @@ -10641,6 +12105,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2048', @@ -10680,6 +12145,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2304', @@ -10726,6 +12192,7 @@ 'model': 'RYSE Shade', 'name': 'Master Bath South', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -10752,6 +12219,7 @@ 'original_icon': None, 'original_name': 'Master Bath South Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2', @@ -10787,6 +12255,7 @@ 'original_icon': None, 'original_name': 'Master Bath South RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_48', @@ -10826,6 +12295,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Master Bath South RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_64', @@ -10868,6 +12338,7 @@ 'model': 'RYSE SmartBridge', 'name': 'RYSE SmartBridge', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.3.0', }), @@ -10894,6 +12365,7 @@ 'original_icon': None, 'original_name': 'RYSE SmartBridge Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -10932,6 +12404,7 @@ 'model': 'RYSE Shade', 'name': 'RYSE SmartShade', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '', }), @@ -10958,6 +12431,7 @@ 'original_icon': None, 'original_name': 'RYSE SmartShade Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_2', @@ -10993,6 +12467,7 @@ 'original_icon': None, 'original_name': 'RYSE SmartShade RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_48', @@ -11032,6 +12507,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'RYSE SmartShade RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_64', @@ -11078,6 +12554,7 @@ 'model': 'RYSE Shade', 'name': 'BR Left', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -11104,6 +12581,7 @@ 'original_icon': None, 'original_name': 'BR Left Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_2', @@ -11139,6 +12617,7 @@ 'original_icon': None, 'original_name': 'BR Left RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_48', @@ -11178,6 +12657,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'BR Left RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_64', @@ -11220,6 +12700,7 @@ 'model': 'RYSE Shade', 'name': 'LR Left', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -11246,6 +12727,7 @@ 'original_icon': None, 'original_name': 'LR Left Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2', @@ -11281,6 +12763,7 @@ 'original_icon': None, 'original_name': 'LR Left RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_48', @@ -11320,6 +12803,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'LR Left RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_64', @@ -11362,6 +12846,7 @@ 'model': 'RYSE Shade', 'name': 'LR Right', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -11388,6 +12873,7 @@ 'original_icon': None, 'original_name': 'LR Right Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_2', @@ -11423,6 +12909,7 @@ 'original_icon': None, 'original_name': 'LR Right RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_48', @@ -11462,6 +12949,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'LR Right RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_64', @@ -11504,6 +12992,7 @@ 'model': 'RYSE SmartBridge', 'name': 'RYSE SmartBridge', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.3.0', }), @@ -11530,6 +13019,7 @@ 'original_icon': None, 'original_name': 'RYSE SmartBridge Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -11568,6 +13058,7 @@ 'model': 'RYSE Shade', 'name': 'RZSS', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -11594,6 +13085,7 @@ 'original_icon': None, 'original_name': 'RZSS Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_5_1_2', @@ -11629,6 +13121,7 @@ 'original_icon': None, 'original_name': 'RZSS RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_5_48', @@ -11668,6 +13161,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'RZSS RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_5_64', @@ -11714,6 +13208,7 @@ 'model': 'BE479CAM619', 'name': 'SENSE ', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '004.027.000', }), @@ -11740,6 +13235,7 @@ 'original_icon': None, 'original_name': 'SENSE Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_3', @@ -11775,6 +13271,7 @@ 'original_icon': None, 'original_name': 'SENSE Lock Mechanism', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30', @@ -11818,6 +13315,7 @@ 'model': 'SIMPLEconnect', 'name': 'SIMPLEconnect Fan-06F674', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '', }), @@ -11844,6 +13342,7 @@ 'original_icon': None, 'original_name': 'SIMPLEconnect Fan-06F674 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -11881,6 +13380,7 @@ 'original_icon': None, 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -11926,6 +13426,7 @@ 'original_icon': None, 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Light', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_29', @@ -11974,6 +13475,7 @@ 'model': 'VELUX Gateway', 'name': 'VELUX Gateway', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '70', }), @@ -12000,6 +13502,7 @@ 'original_icon': None, 'original_name': 'VELUX Gateway Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -12038,6 +13541,7 @@ 'model': 'VELUX Sensor', 'name': 'VELUX Sensor', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '16', }), @@ -12064,6 +13568,7 @@ 'original_icon': None, 'original_name': 'VELUX Sensor Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_7', @@ -12101,6 +13606,7 @@ 'original_icon': None, 'original_name': 'VELUX Sensor Carbon Dioxide sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_14', @@ -12141,6 +13647,7 @@ 'original_icon': None, 'original_name': 'VELUX Sensor Humidity sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_11', @@ -12181,6 +13688,7 @@ 'original_icon': None, 'original_name': 'VELUX Sensor Temperature sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_8', @@ -12222,6 +13730,7 @@ 'model': 'VELUX Window', 'name': 'VELUX Window', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '48', }), @@ -12248,6 +13757,7 @@ 'original_icon': None, 'original_name': 'VELUX Window Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_7', @@ -12283,6 +13793,7 @@ 'original_icon': None, 'original_name': 'VELUX Window Roof Window', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_8', @@ -12328,6 +13839,7 @@ 'model': 'Flowerbud', 'name': 'VOCOlinc-Flowerbud-0d324b', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.121.2', }), @@ -12354,6 +13866,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-Flowerbud-0d324b Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -12396,6 +13909,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-Flowerbud-0d324b', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30', @@ -12446,6 +13960,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9', @@ -12505,6 +14020,7 @@ 'original_icon': 'mdi:water', 'original_name': 'VOCOlinc-Flowerbud-0d324b Spray Quantity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_38', @@ -12547,6 +14063,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-Flowerbud-0d324b Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_33', @@ -12592,6 +14109,7 @@ 'model': 'VP3', 'name': 'VOCOlinc-VP3-123456', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.101.2', }), @@ -12618,6 +14136,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-VP3-123456 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -12655,6 +14174,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-VP3-123456 Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48_97', @@ -12693,6 +14213,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-VP3-123456 Outlet', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48', diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 1cdd4ccb907..7b721e76bba 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -252,3 +252,92 @@ async def test_ecobee3_add_sensors_at_runtime(hass: HomeAssistant) -> None: occ3 = entity_registry.async_get("binary_sensor.basement") assert occ3.unique_id == "00:00:00:00:00:00_4_56" + + +async def test_ecobee3_remove_sensors_at_runtime(hass: HomeAssistant) -> None: + """Test that sensors are automatically removed.""" + entity_registry = er.async_get(hass) + + # Set up a base Ecobee 3 with additional sensors. + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + await setup_test_accessories(hass, accessories) + + climate = entity_registry.async_get("climate.homew") + assert climate.unique_id == "00:00:00:00:00:00_1_16" + + occ1 = entity_registry.async_get("binary_sensor.kitchen") + assert occ1.unique_id == "00:00:00:00:00:00_2_56" + + occ2 = entity_registry.async_get("binary_sensor.porch") + assert occ2.unique_id == "00:00:00:00:00:00_3_56" + + occ3 = entity_registry.async_get("binary_sensor.basement") + assert occ3.unique_id == "00:00:00:00:00:00_4_56" + + assert hass.states.get("binary_sensor.kitchen") is not None + assert hass.states.get("binary_sensor.porch") is not None + assert hass.states.get("binary_sensor.basement") is not None + + # Now remove 3 new sensors at runtime - sensors should disappear and climate + # shouldn't be duplicated. + accessories = await setup_accessories_from_file(hass, "ecobee3_no_sensors.json") + await device_config_changed(hass, accessories) + + assert hass.states.get("binary_sensor.kitchen") is None + assert hass.states.get("binary_sensor.porch") is None + assert hass.states.get("binary_sensor.basement") is None + + # Now add the sensors back + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + await device_config_changed(hass, accessories) + + occ1 = entity_registry.async_get("binary_sensor.kitchen") + assert occ1.unique_id == "00:00:00:00:00:00_2_56" + + occ2 = entity_registry.async_get("binary_sensor.porch") + assert occ2.unique_id == "00:00:00:00:00:00_3_56" + + occ3 = entity_registry.async_get("binary_sensor.basement") + assert occ3.unique_id == "00:00:00:00:00:00_4_56" + + # Ensure the sensors are back + assert hass.states.get("binary_sensor.kitchen") is not None + assert hass.states.get("binary_sensor.porch") is not None + assert hass.states.get("binary_sensor.basement") is not None + + +async def test_ecobee3_services_and_chars_removed( + hass: HomeAssistant, +) -> None: + """Test handling removal of some services and chars.""" + entity_registry = er.async_get(hass) + + # Set up a base Ecobee 3 with additional sensors. + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + await setup_test_accessories(hass, accessories) + + climate = entity_registry.async_get("climate.homew") + assert climate.unique_id == "00:00:00:00:00:00_1_16" + + assert hass.states.get("sensor.basement_temperature") is not None + assert hass.states.get("sensor.kitchen_temperature") is not None + assert hass.states.get("sensor.porch_temperature") is not None + + assert hass.states.get("select.homew_current_mode") is not None + assert hass.states.get("button.homew_clear_hold") is not None + + # Reconfigure with some of the chars removed and the basement temperature sensor + accessories = await setup_accessories_from_file( + hass, "ecobee3_service_removed.json" + ) + await device_config_changed(hass, accessories) + + # Make sure the climate entity is still there + assert hass.states.get("climate.homew") is not None + + # Make sure the basement temperature sensor is gone + assert hass.states.get("sensor.basement_temperature") is None + + # Make sure the current mode select and clear hold button are gone + assert hass.states.get("select.homew_current_mode") is None + assert hass.states.get("button.homew_clear_hold") is None diff --git a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py new file mode 100644 index 00000000000..bae0c0e4ff1 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py @@ -0,0 +1,142 @@ +"""Test for a Home Assistant bridge that changes fan features at runtime.""" + + +from homeassistant.components.fan import FanEntityFeature +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None: + """Test that new features can be added at runtime.""" + entity_registry = er.async_get(hass) + + # Set up a basic fan that does not support oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_fan.json" + ) + await setup_test_accessories(hass, accessories) + + fan = entity_registry.async_get("fan.living_room_fan") + assert fan.unique_id == "00:00:00:00:00:00_1256851357_8" + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION + ) + + fan = entity_registry.async_get("fan.ceiling_fan") + assert fan.unique_id == "00:00:00:00:00:00_766313939_8" + + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + # Now change the config to add oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan.json" + ) + await device_config_changed(hass, accessories) + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + +async def test_fan_remove_feature_at_runtime(hass: HomeAssistant) -> None: + """Test that features can be removed at runtime.""" + entity_registry = er.async_get(hass) + + # Set up a basic fan that does not support oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan.json" + ) + await setup_test_accessories(hass, accessories) + + fan = entity_registry.async_get("fan.living_room_fan") + assert fan.unique_id == "00:00:00:00:00:00_1256851357_8" + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + + fan = entity_registry.async_get("fan.ceiling_fan") + assert fan.unique_id == "00:00:00:00:00:00_766313939_8" + + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + # Now change the config to add oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_fan.json" + ) + await device_config_changed(hass, accessories) + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION + ) + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + +async def test_bridge_with_two_fans_one_removed(hass: HomeAssistant) -> None: + """Test a bridge with two fans and one gets removed.""" + entity_registry = er.async_get(hass) + + # Set up a basic fan that does not support oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan.json" + ) + await setup_test_accessories(hass, accessories) + + fan = entity_registry.async_get("fan.living_room_fan") + assert fan.unique_id == "00:00:00:00:00:00_1256851357_8" + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + + fan = entity_registry.async_get("fan.ceiling_fan") + assert fan.unique_id == "00:00:00:00:00:00_766313939_8" + + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + # Now change the config to remove one of the fans + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan_one_removed.json" + ) + await device_config_changed(hass, accessories) + + # Verify the first fan is still there + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + # The second fan should have been removed + assert not hass.states.get("fan.ceiling_fan") diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index e25d5b7830e..2c2c0b5e1c5 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -7,6 +7,9 @@ from aiohomekit.model import CharacteristicsTypes, ServicesTypes from aiohomekit.testing import FakePairing import pytest +from homeassistant.components.homekit_controller.connection import ( + MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -26,8 +29,6 @@ async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") config_entry, pairing = await setup_test_accessories(hass, accessories) - pairing.testing.events_enabled = False - helper = Helper( hass, "light.koogeek_ls1_20833f_light_strip", @@ -49,11 +50,10 @@ async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> with mock.patch.object(FakePairing, "get_characteristics") as get_char: get_char.side_effect = failure_cls("Disconnected") - # Set light state on fake device to on - state = await helper.async_update( - ServicesTypes.LIGHTBULB, {CharacteristicsTypes.ON: True} - ) - assert state.state == "off" + # Test that a poll triggers unavailable + for _ in range(MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE + 2): + state = await helper.poll_and_get_state() + assert state.state == "unavailable" chars = get_char.call_args[0][0] assert set(chars) == {(1, 8), (1, 9), (1, 10), (1, 11)} diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py index 7fd5b11d5d6..4b5372d980d 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -270,6 +270,11 @@ async def test_config_entry( "friendly_name": "Koogeek-LS1-20833F Light Strip", "supported_color_modes": ["hs"], "supported_features": 0, + "brightness": None, + "color_mode": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, }, "entity_id": "light.koogeek_ls1_20833f_light_strip", "last_changed": ANY, @@ -541,6 +546,11 @@ async def test_device( "friendly_name": "Koogeek-LS1-20833F Light Strip", "supported_color_modes": ["hs"], "supported_features": 0, + "brightness": None, + "color_mode": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, }, "entity_id": "light.koogeek_ls1_20833f_light_strip", "last_changed": ANY, diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 3187808a0c5..d6b36fca22e 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -114,7 +114,7 @@ async def test_switch_read_light_state_dimmer(hass: HomeAssistant, utcnow) -> No # Initial state is that the light is off state = await helper.poll_and_get_state() assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -177,7 +177,7 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant, utcnow) -> None: # Initial state is that the light is off state = await helper.poll_and_get_state() assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -246,7 +246,7 @@ async def test_switch_read_light_state_color_temp(hass: HomeAssistant, utcnow) - # Initial state is that the light is off state = await helper.poll_and_get_state() assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 6c9ad008703..829fe8e3cdc 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -311,10 +311,9 @@ async def test_sensor_unavailable(hass: HomeAssistant, utcnow) -> None: """Test a sensor becoming unavailable.""" helper = await setup_test_component(hass, create_switch_with_sensor) - # Find the energy sensor and mark it as offline outlet = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + on_char = outlet[CharacteristicsTypes.ON] realtime_energy = outlet[CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY] - realtime_energy.status = HapStatusCode.UNABLE_TO_COMMUNICATE # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. energy_helper = Helper( @@ -325,10 +324,32 @@ async def test_sensor_unavailable(hass: HomeAssistant, utcnow) -> None: helper.config_entry, ) + # Find the outlet on char and mark it as offline + await helper.async_set_aid_iid_status( + [ + ( + helper.accessory.aid, + on_char.iid, + HapStatusCode.UNABLE_TO_COMMUNICATE.value, + ) + ] + ) + # Outlet has non-responsive characteristics so should be unavailable state = await helper.poll_and_get_state() assert state.state == "unavailable" + # Find the energy sensor and mark it as offline + await helper.async_set_aid_iid_status( + [ + ( + energy_helper.accessory.aid, + realtime_energy.iid, + HapStatusCode.UNABLE_TO_COMMUNICATE.value, + ) + ] + ) + # Energy sensor has non-responsive characteristics so should be unavailable state = await energy_helper.poll_and_get_state() assert state.state == "unavailable" diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index 8f0200373d2..517978e74c0 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -55,7 +55,7 @@ async def test_hmip_light(hass: HomeAssistant, default_mock_hap_factory) -> None await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -87,7 +87,7 @@ async def test_hmip_notification_light( ) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION service_call_counter = len(hmip_device.mock_calls) @@ -184,7 +184,7 @@ async def test_hmip_dimmer(hass: HomeAssistant, default_mock_hap_factory) -> Non ) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == 0 service_call_counter = len(hmip_device.mock_calls) @@ -244,7 +244,7 @@ async def test_hmip_light_measuring( ) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == 0 service_call_counter = len(hmip_device.mock_calls) @@ -290,7 +290,7 @@ async def test_hmip_wired_multi_dimmer( ) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == 0 service_call_counter = len(hmip_device.mock_calls) diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5e1025a8d31 --- /dev/null +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': -4, + 'active_current_l2_a': 2, + 'active_current_l3_a': 0, + 'active_frequency_hz': 50, + 'active_liter_lpm': 12.345, + 'active_power_average_w': 123.0, + 'active_power_l1_w': -123, + 'active_power_l2_w': 456, + 'active_power_l3_w': 123.456, + 'active_power_w': -123, + 'active_tariff': 2, + 'active_voltage_l1_v': 230.111, + 'active_voltage_l2_v': 230.222, + 'active_voltage_l3_v': 230.333, + 'any_power_fail_count': 4, + 'external_devices': None, + 'gas_timestamp': '2021-03-14T11:22:33', + 'gas_unique_id': '**REDACTED**', + 'long_power_fail_count': 5, + 'meter_model': 'ISKRA 2M550T-101', + 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', + 'monthly_power_peak_w': 1111.0, + 'smr_version': 50, + 'total_gas_m3': 1122.333, + 'total_liter_m3': 1234.567, + 'total_power_export_kwh': 13086.777, + 'total_power_export_t1_kwh': 4321.333, + 'total_power_export_t2_kwh': 8765.444, + 'total_power_export_t3_kwh': None, + 'total_power_export_t4_kwh': None, + 'total_power_import_kwh': 13779.338, + 'total_power_import_t1_kwh': 10830.511, + 'total_power_import_t2_kwh': 2948.827, + 'total_power_import_t3_kwh': None, + 'total_power_import_t4_kwh': None, + 'unique_meter_id': '**REDACTED**', + 'voltage_sag_l1_count': 1, + 'voltage_sag_l2_count': 2, + 'voltage_sag_l3_count': 3, + 'voltage_swell_l1_count': 4, + 'voltage_swell_l2_count': 5, + 'voltage_swell_l3_count': 6, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.11', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'state': dict({ + 'brightness': 255, + 'power_on': True, + 'switch_lock': False, + }), + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 7c6fb0bdb0d..770496b5612 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -43,7 +43,7 @@ async def test_manual_flow_works( ) assert result["type"] == "create_entry" - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -68,8 +68,8 @@ async def test_discovery_flow_works( properties={ "api_enabled": "1", "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", + "product_name": "Energy Socket", + "product_type": "HWE-SKT", "serial": "aabbccddeeff", }, ) @@ -109,11 +109,11 @@ async def test_discovery_flow_works( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "Energy Socket" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] - assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + assert result["result"].unique_id == "HWE-SKT_aabbccddeeff" async def test_discovery_flow_during_onboarding( @@ -149,7 +149,7 @@ async def test_discovery_flow_during_onboarding( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] @@ -214,7 +214,7 @@ async def test_discovery_flow_during_onboarding_disabled_api( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 64e8b0c6dfd..9e9797439b3 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -1,6 +1,7 @@ """Tests for diagnostics data.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,67 +13,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": {"ip_address": REDACTED}, - "data": { - "device": { - "product_name": "P1 Meter", - "product_type": "HWE-SKT", - "serial": REDACTED, - "api_version": "v1", - "firmware_version": "2.11", - }, - "data": { - "wifi_ssid": REDACTED, - "wifi_strength": 100, - "smr_version": 50, - "meter_model": "ISKRA 2M550T-101", - "unique_meter_id": REDACTED, - "active_tariff": 2, - "total_power_import_kwh": 13779.338, - "total_power_import_t1_kwh": 10830.511, - "total_power_import_t2_kwh": 2948.827, - "total_power_import_t3_kwh": None, - "total_power_import_t4_kwh": None, - "total_power_export_kwh": 13086.777, - "total_power_export_t1_kwh": 4321.333, - "total_power_export_t2_kwh": 8765.444, - "total_power_export_t3_kwh": None, - "total_power_export_t4_kwh": None, - "active_power_w": -123, - "active_power_l1_w": -123, - "active_power_l2_w": 456, - "active_power_l3_w": 123.456, - "active_voltage_l1_v": 230.111, - "active_voltage_l2_v": 230.222, - "active_voltage_l3_v": 230.333, - "active_current_l1_a": -4, - "active_current_l2_a": 2, - "active_current_l3_a": 0, - "active_frequency_hz": 50, - "voltage_sag_l1_count": 1, - "voltage_sag_l2_count": 2, - "voltage_sag_l3_count": 3, - "voltage_swell_l1_count": 4, - "voltage_swell_l2_count": 5, - "voltage_swell_l3_count": 6, - "any_power_fail_count": 4, - "long_power_fail_count": 5, - "active_power_average_w": 123.0, - "monthly_power_peak_w": 1111.0, - "monthly_power_peak_timestamp": "2023-01-01T08:00:10", - "total_gas_m3": 1122.333, - "gas_timestamp": "2021-03-14T11:22:33", - "gas_unique_id": REDACTED, - "active_liter_lpm": 12.345, - "total_liter_m3": 1234.567, - "external_devices": None, - }, - "state": {"power_on": True, "switch_lock": False, "brightness": 255}, - "system": {"cloud_enabled": True}, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 7bd76cb8522..53cb70475c9 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -28,7 +28,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.honeywell.climate import SCAN_INTERVAL +from homeassistant.components.honeywell.climate import RETRY, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -1083,6 +1083,17 @@ async def test_async_update_errors( state = hass.states.get(entity_id) assert state.state == "off" + # Due to server instability, only mark entity unavailable after RETRY update attempts + for _ in range(RETRY): + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + async_fire_time_changed( hass, utcnow() + SCAN_INTERVAL, @@ -1126,7 +1137,6 @@ async def test_async_update_errors( 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( @@ -1139,6 +1149,18 @@ async def test_async_update_errors( assert state.state == "off" device.refresh.side_effect = ClientConnectionError + + # Due to server instability, only mark entity unavailable after RETRY update attempts + for _ in range(RETRY): + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + async_fire_time_changed( hass, utcnow() + SCAN_INTERVAL, diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 3fc8d7689d6..5a5bffe6748 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -289,7 +289,7 @@ async def test_emergency_ssl_certificate_when_invalid( _setup_broken_ssl_pem_files, tmp_path ) - hass.config.safe_mode = True + hass.config.recovery_mode = True assert ( await async_setup_component( hass, @@ -304,17 +304,17 @@ async def test_emergency_ssl_certificate_when_invalid( await hass.async_start() await hass.async_block_till_done() assert ( - "Home Assistant is running in safe mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" + "Home Assistant is running in recovery mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" in caplog.text ) assert hass.http.site is not None -async def test_emergency_ssl_certificate_not_used_when_not_safe_mode( +async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode( hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Test an emergency cert is only used in safe mode.""" + """Test an emergency cert is only used in recovery mode.""" cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path @@ -338,7 +338,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - hass.config.safe_mode = True + hass.config.recovery_mode = True with patch( "homeassistant.components.http.get_url", side_effect=NoURLAvailableError @@ -358,7 +358,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( assert len(mock_get_url.mock_calls) == 1 assert ( - "Home Assistant is running in safe mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" + "Home Assistant is running in recovery mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" in caplog.text ) @@ -373,7 +373,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - hass.config.safe_mode = True + hass.config.recovery_mode = True with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError @@ -410,7 +410,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, tmp_path ) - hass.config.safe_mode = True + hass.config.recovery_mode = True with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError diff --git a/tests/components/huawei_lte/test_switches.py b/tests/components/huawei_lte/test_switches.py index 733d26e5471..dee4def9596 100644 --- a/tests/components/huawei_lte/test_switches.py +++ b/tests/components/huawei_lte/test_switches.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from tests.common import MockConfigEntry -SWITCH_WIFI_GUEST_NETWORK = "switch.lte_wifi_guest_network" +SWITCH_WIFI_GUEST_NETWORK = "switch.lte_wi_fi_guest_network" def magic_client(multi_basic_settings_value: dict) -> MagicMock: diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index d730d3f18f5..3350ea15185 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -3,6 +3,7 @@ import asyncio from collections import deque import json import logging +from typing import Any from unittest.mock import AsyncMock, Mock, patch import aiohue.v1 as aiohue_v1 @@ -12,6 +13,7 @@ import pytest from homeassistant.components import hue from homeassistant.components.hue.v1 import sensor_base as hue_sensor_base +from homeassistant.components.hue.v2.device import async_setup_devices from homeassistant.setup import async_setup_component from tests.common import ( @@ -20,6 +22,7 @@ from tests.common import ( load_fixture, mock_device_registry, ) +from tests.components.hue.const import FAKE_BRIDGE, FAKE_BRIDGE_DEVICE @pytest.fixture(autouse=True) @@ -56,6 +59,8 @@ def create_mock_bridge(hass, api_version=1): async def async_initialize_bridge(): if bridge.config_entry: hass.data.setdefault(hue.DOMAIN, {})[bridge.config_entry.entry_id] = bridge + if bridge.api_version == 2: + await async_setup_devices(bridge) return True bridge.async_initialize_bridge = async_initialize_bridge @@ -140,22 +145,10 @@ def create_mock_api_v2(hass): """Create a mock V2 API.""" api = Mock(spec=aiohue_v2.HueBridgeV2) api.initialize = AsyncMock() - api.config = Mock( - bridge_id="aabbccddeeffggh", - mac_address="00:17:88:01:aa:bb:fd:c7", - model_id="BSB002", - api_version="9.9.9", - software_version="1935144040", - bridge_device=Mock( - id="4a507550-8742-4087-8bf5-c2334f29891c", - product_data=Mock(manufacturer_name="Mock"), - ), - spec=aiohue_v2.ConfigController, - ) - api.config.name = "Home" api.mock_requests = [] api.logger = logging.getLogger(__name__) + api.config = aiohue_v2.ConfigController(api) api.events = aiohue_v2.EventStream(api) api.devices = aiohue_v2.DevicesController(api) api.lights = aiohue_v2.LightsController(api) @@ -171,9 +164,13 @@ def create_mock_api_v2(hass): api.request = mock_request - async def load_test_data(data): + async def load_test_data(data: list[dict[str, Any]]): """Load test data into controllers.""" - api.config = aiohue_v2.ConfigController(api) + + # append default bridge if none explicitly given in test data + if not any(x for x in data if x["type"] == "bridge"): + data.append(FAKE_BRIDGE) + data.append(FAKE_BRIDGE_DEVICE) await asyncio.gather( api.config.initialize(data), diff --git a/tests/components/hue/const.py b/tests/components/hue/const.py index 415fe1324b7..252c9da9a9d 100644 --- a/tests/components/hue/const.py +++ b/tests/components/hue/const.py @@ -1,5 +1,29 @@ """Constants for Hue tests.""" +FAKE_BRIDGE = { + "bridge_id": "aabbccddeeffggh", + "id": "07dd5849-abcd-efgh-b9b9-eb540408ce00", + "id_v1": "", + "owner": {"rid": "4a507550-8742-4087-8bf5-c2334f29891c", "rtype": "device"}, + "time_zone": {"time_zone": "Europe/Amsterdam"}, + "type": "bridge", +} + +FAKE_BRIDGE_DEVICE = { + "id": "4a507550-8742-4087-8bf5-c2334f29891c", + "id_v1": "", + "metadata": {"archetype": "bridge_v2", "name": "Philips hue"}, + "product_data": { + "certified": True, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "BSB002", + "product_archetype": "bridge_v2", + "product_name": "Philips hue", + "software_version": "1.50.1950111030", + }, + "services": [{"rid": "07dd5849-abcd-efgh-b9b9-eb540408ce00", "rtype": "bridge"}], + "type": "device", +} FAKE_DEVICE = { "id": "fake_device_id_1", diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 24f433f539c..662e1107ca9 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2050,37 +2050,53 @@ "type": "temperature" }, { + "id": "9ad57767-e622-4f91-9086-2e5573bc781b", + "type": "behavior_instance", + "script_id": "e73bc72d-96b1-46f8-aa57-729861f80c78", + "enabled": true, + "state": { + "timer_state": "stopped" + }, "configuration": { - "end_state": "last_state", + "duration": { + "seconds": 60 + }, + "what": [ + { + "group": { + "rid": "5e799732-e82e-46ab-b5d9-52b701bd7cbc", + "rtype": "room" + }, + "recall": { + "rid": "732ff1d9-76a7-4630-aad0-c8acc499bb0b", + "rtype": "recipe" + } + } + ], "where": [ { "group": { - "rid": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", - "rtype": "entertainment_configuration" + "rid": "5e799732-e82e-46ab-b5d9-52b701bd7cbc", + "rtype": "room" } } ] }, "dependees": [ { - "level": "critical", "target": { - "rid": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", - "rtype": "entertainment_configuration" + "rid": "5e799732-e82e-46ab-b5d9-52b701bd7cbc", + "rtype": "room" }, + "level": "critical", "type": "ResourceDependee" } ], - "enabled": true, - "id": "0670cfb1-2bd7-4237-a0e3-1827a44d7231", + "status": "running", "last_error": "", "metadata": { - "name": "state_after_streaming" - }, - "migrated_from": "/resourcelinks/47450", - "script_id": "7719b841-6b3d-448d-a0e7-601ae9edb6a2", - "status": "running", - "type": "behavior_instance" + "name": "Timer Test" + } }, { "configuration_schema": { diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 3846f17aa76..ab6f4ab0581 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -25,19 +25,17 @@ async def test_binary_sensors( assert sensor.attributes["device_class"] == "motion" # test entertainment room active sensor - sensor = hass.states.get( - "binary_sensor.entertainmentroom_1_entertainment_configuration" - ) + sensor = hass.states.get("binary_sensor.entertainmentroom_1") assert sensor is not None assert sensor.state == "off" - assert sensor.name == "Entertainmentroom 1: Entertainment Configuration" + assert sensor.name == "Entertainmentroom 1" assert sensor.attributes["device_class"] == "running" # test contact sensor - sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + sensor = hass.states.get("binary_sensor.test_contact_sensor_opening") assert sensor is not None assert sensor.state == "off" - assert sensor.name == "Test contact sensor Contact" + assert sensor.name == "Test contact sensor Opening" assert sensor.attributes["device_class"] == "opening" # test contact sensor disabled == state unknown mock_bridge_v2.api.emit_event( @@ -49,7 +47,7 @@ async def test_binary_sensors( }, ) await hass.async_block_till_done() - sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + sensor = hass.states.get("binary_sensor.test_contact_sensor_opening") assert sensor.state == "unknown" # test tamper sensor diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py index a3779c6b0e3..9953bb11796 100644 --- a/tests/components/hue/test_event.py +++ b/tests/components/hue/test_event.py @@ -57,7 +57,7 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) await setup_platform(hass, mock_bridge_v2, "event") - test_entity_id = "event.hue_mocked_device_relative_rotary" + test_entity_id = "event.hue_mocked_device_rotary" # verify entity does not exist before we start assert hass.states.get(test_entity_id) is None @@ -70,7 +70,7 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: state = hass.states.get(test_entity_id) assert state is not None assert state.state == "unknown" - assert state.name == "Hue mocked device Relative Rotary" + assert state.name == "Hue mocked device Rotary" # check event_types assert state.attributes[ATTR_EVENT_TYPES] == ["clock_wise", "counter_clock_wise"] diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index abdbb816364..919f95b6a66 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -242,7 +242,7 @@ async def test_lights_color_mode(hass: HomeAssistant, mock_bridge_v1) -> None: assert lamp_1.state == "on" assert lamp_1.attributes["brightness"] == 145 assert lamp_1.attributes["hs_color"] == (36.067, 69.804) - assert "color_temp" not in lamp_1.attributes + assert lamp_1.attributes["color_temp"] is None assert lamp_1.attributes["color_mode"] == ColorMode.HS assert lamp_1.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 1dd20bc1350..c32abecbd0b 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -217,6 +217,7 @@ async def test_light_turn_off_service( # verify the light is on before we start assert hass.states.get(test_light_id).state == "on" + brightness_pct = hass.states.get(test_light_id).attributes["brightness"] / 255 * 100 # now call the HA turn_off service await hass.services.async_call( @@ -256,6 +257,23 @@ async def test_light_turn_off_service( assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is False assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 200 + # test turn_on resets brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 3 + assert mock_bridge_v2.mock_requests[2]["json"]["on"]["on"] is True + assert ( + round( + mock_bridge_v2.mock_requests[2]["json"]["dimming"]["brightness"] + - brightness_pct + ) + == 0 + ) + # test again with sending long flash await hass.services.async_call( "light", @@ -263,8 +281,8 @@ async def test_light_turn_off_service( {"entity_id": test_light_id, "flash": "long"}, blocking=True, ) - assert len(mock_bridge_v2.mock_requests) == 3 - assert mock_bridge_v2.mock_requests[2]["json"]["alert"]["action"] == "breathe" + assert len(mock_bridge_v2.mock_requests) == 4 + assert mock_bridge_v2.mock_requests[3]["json"]["alert"]["action"] == "breathe" # test again with sending short flash await hass.services.async_call( @@ -273,8 +291,8 @@ async def test_light_turn_off_service( {"entity_id": test_light_id, "flash": "short"}, blocking=True, ) - assert len(mock_bridge_v2.mock_requests) == 4 - assert mock_bridge_v2.mock_requests[3]["json"]["identify"]["action"] == "identify" + assert len(mock_bridge_v2.mock_requests) == 5 + assert mock_bridge_v2.mock_requests[4]["json"]["identify"]["action"] == "identify" async def test_light_added(hass: HomeAssistant, mock_bridge_v2) -> None: @@ -481,6 +499,17 @@ async def test_grouped_lights( assert mock_bridge_v2.mock_requests[0]["json"]["on"]["on"] is False assert mock_bridge_v2.mock_requests[0]["json"]["dynamics"]["duration"] == 200 + # Test turn_on resets brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is True + assert mock_bridge_v2.mock_requests[1]["json"]["dimming"]["brightness"] == 100 + # Test sending short flash effect to a grouped light mock_bridge_v2.mock_requests.clear() test_light_id = "light.test_zone" diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index 7785b9d4628..5fa35cec5b4 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -186,7 +186,7 @@ async def test_scene_updates( ) await hass.async_block_till_done() test_entity = hass.states.get(test_entity_id) - assert test_entity.name == "Test Room 2 Mocked Scene" + assert test_entity.attributes["group_name"] == "Test Room 2" # # test delete mock_bridge_v2.api.emit_event("delete", updated_resource) diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index a576b88a7c3..c3384ae1e44 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -14,13 +14,20 @@ async def test_switch( await setup_platform(hass, mock_bridge_v2, "switch") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 3 entities should be created from test data - assert len(hass.states.async_all()) == 3 + # 4 entities should be created from test data + assert len(hass.states.async_all()) == 4 # test config switch to enable/disable motion sensor - test_entity = hass.states.get("switch.hue_motion_sensor_motion") + test_entity = hass.states.get("switch.hue_motion_sensor_motion_sensor_enabled") assert test_entity is not None - assert test_entity.name == "Hue motion sensor Motion" + assert test_entity.name == "Hue motion sensor Motion sensor enabled" + assert test_entity.state == "on" + assert test_entity.attributes["device_class"] == "switch" + + # test config switch to enable/disable a behavior_instance resource (=builtin automation) + test_entity = hass.states.get("switch.automation_timer_test") + assert test_entity is not None + assert test_entity.name == "Automation: Timer Test" assert test_entity.state == "on" assert test_entity.attributes["device_class"] == "switch" @@ -33,7 +40,7 @@ async def test_switch_turn_on_service( await setup_platform(hass, mock_bridge_v2, "switch") - test_entity_id = "switch.hue_motion_sensor_motion" + test_entity_id = "switch.hue_motion_sensor_motion_sensor_enabled" # call the HA turn_on service await hass.services.async_call( @@ -57,7 +64,7 @@ async def test_switch_turn_off_service( await setup_platform(hass, mock_bridge_v2, "switch") - test_entity_id = "switch.hue_motion_sensor_motion" + test_entity_id = "switch.hue_motion_sensor_motion_sensor_enabled" # verify the switch is on before we start assert hass.states.get(test_entity_id).state == "on" @@ -96,7 +103,7 @@ async def test_switch_added(hass: HomeAssistant, mock_bridge_v2) -> None: await setup_platform(hass, mock_bridge_v2, "switch") - test_entity_id = "switch.hue_mocked_device_motion" + test_entity_id = "switch.hue_mocked_device_motion_sensor_enabled" # verify entity does not exist before we start assert hass.states.get(test_entity_id) is None diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index b324a5be970..3f0bdae8e53 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -222,10 +222,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: energy_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY ) assert energy_today.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" - assert ( - energy_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.MEASUREMENT - ) + assert energy_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( energy_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR @@ -239,8 +236,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_week.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.MEASUREMENT + energy_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -255,8 +251,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_month.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.MEASUREMENT + energy_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -271,8 +266,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_year.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.MEASUREMENT + energy_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -295,10 +289,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_today.state == "1.1" assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_today.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -308,10 +299,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_week.state == "5.6" assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_week.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -321,10 +309,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_month.state == "39.1" assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_month.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -334,10 +319,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_year.state == "116.7" assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_year.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 600be154fc7..ff508bd3a67 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -142,9 +142,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -164,7 +176,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -176,7 +188,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -185,7 +197,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_toggle"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -197,7 +209,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_humidity", "humidity": 35, @@ -210,7 +222,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_mode", "mode": const.MODE_AWAY, @@ -290,10 +302,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -313,7 +335,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_mode", "mode": const.MODE_AWAY, diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index bf8eb98f456..224c69b9fb5 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -149,10 +149,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) @@ -167,7 +178,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -186,7 +197,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -205,7 +216,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_mode", "mode": "away", @@ -254,10 +265,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) @@ -272,7 +294,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_mode", "mode": "away", diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 1953494e0c0..34067d96ff2 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -162,10 +162,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -188,7 +199,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "target_humidity_changed", "below": 20, @@ -202,7 +213,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "target_humidity_changed", "above": 30, @@ -216,7 +227,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "target_humidity_changed", "above": 30, @@ -231,7 +242,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "below": 30, @@ -245,7 +256,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "above": 40, @@ -259,7 +270,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "above": 40, @@ -274,7 +285,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -298,7 +309,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -322,7 +333,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -423,10 +434,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -448,7 +470,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "target_humidity_changed", "below": 20, diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 30989018152..4a6c8372e57 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -33,6 +33,9 @@ def mock_pydrawise( mock_pydrawise.return_value.current_controller = mock_controller mock_pydrawise.return_value.controller_status = {"relays": mock_zones} mock_pydrawise.return_value.relays = mock_zones + mock_pydrawise.return_value.relays_by_zone_number = { + r["relay"]: r for r in mock_zones + } yield mock_pydrawise.return_value diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py new file mode 100644 index 00000000000..c60f4392f1e --- /dev/null +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -0,0 +1,48 @@ +"""Test Hydrawise binary_sensor.""" + +from datetime import timedelta +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.hydrawise.const import SCAN_INTERVAL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_states( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary_sensor states.""" + connectivity = hass.states.get("binary_sensor.home_controller_connectivity") + assert connectivity is not None + assert connectivity.state == "on" + + watering1 = hass.states.get("binary_sensor.zone_one_watering") + assert watering1 is not None + assert watering1.state == "off" + + watering2 = hass.states.get("binary_sensor.zone_two_watering") + assert watering2 is not None + assert watering2.state == "on" + + +async def test_update_data_fails( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that no data from the API sets the correct connectivity.""" + # Make the coordinator refresh data. + mock_pydrawise.update_controller_info.return_value = None + freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + connectivity = hass.states.get("binary_sensor.home_controller_connectivity") + assert connectivity is not None + assert connectivity.state == "unavailable" diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py new file mode 100644 index 00000000000..79cea94d479 --- /dev/null +++ b/tests/components/hydrawise/test_init.py @@ -0,0 +1,51 @@ +"""Tests for the Hydrawise integration.""" + +from unittest.mock import Mock + +from requests.exceptions import HTTPError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) -> None: + """Test that setup with a YAML config triggers an import and warning.""" + mock_pydrawise.update_controller_info.return_value = True + mock_pydrawise.customer_id = 12345 + mock_pydrawise.status = "unknown" + config = {"hydrawise": {CONF_ACCESS_TOKEN: "_access-token_"}} + assert await async_setup_component(hass, "hydrawise", config) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_hydrawise" + ) + assert issue.translation_key == "deprecated_yaml" + + +async def test_connect_retry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock +) -> None: + """Test that a connection error triggers a retry.""" + mock_pydrawise.update_controller_info.side_effect = HTTPError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_no_data( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock +) -> None: + """Test that no data from the API triggers a retry.""" + mock_pydrawise.update_controller_info.return_value = False + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/hydrawise/test_sensor.py b/tests/components/hydrawise/test_sensor.py new file mode 100644 index 00000000000..c6d3fecab65 --- /dev/null +++ b/tests/components/hydrawise/test_sensor.py @@ -0,0 +1,28 @@ +"""Test Hydrawise sensor.""" + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") +async def test_states( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor states.""" + watering_time1 = hass.states.get("sensor.zone_one_watering_time") + assert watering_time1 is not None + assert watering_time1.state == "0" + + watering_time2 = hass.states.get("sensor.zone_two_watering_time") + assert watering_time2 is not None + assert watering_time2.state == "29" + + next_cycle = hass.states.get("sensor.zone_one_next_cycle") + assert next_cycle is not None + assert next_cycle.state == "2023-10-04T19:49:57+00:00" diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py new file mode 100644 index 00000000000..39d789f4cf9 --- /dev/null +++ b/tests/components/hydrawise/test_switch.py @@ -0,0 +1,78 @@ +"""Test Hydrawise switch.""" + +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_states( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test switch states.""" + watering1 = hass.states.get("switch.zone_one_manual_watering") + assert watering1 is not None + assert watering1.state == "off" + + watering2 = hass.states.get("switch.zone_two_manual_watering") + assert watering2 is not None + assert watering2.state == "on" + + auto_watering1 = hass.states.get("switch.zone_one_automatic_watering") + assert auto_watering1 is not None + assert auto_watering1.state == "on" + + auto_watering2 = hass.states.get("switch.zone_two_automatic_watering") + assert auto_watering2 is not None + assert auto_watering2.state == "off" + + +async def test_manual_watering_services( + hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, mock_pydrawise: Mock +) -> None: + """Test Manual Watering services.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "switch.zone_one_manual_watering"}, + blocking=True, + ) + mock_pydrawise.run_zone.assert_called_once_with(15, 1) + mock_pydrawise.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "switch.zone_one_manual_watering"}, + blocking=True, + ) + mock_pydrawise.run_zone.assert_called_once_with(0, 1) + + +async def test_auto_watering_services( + hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, mock_pydrawise: Mock +) -> None: + """Test Automatic Watering services.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "switch.zone_one_automatic_watering"}, + blocking=True, + ) + mock_pydrawise.suspend_zone.assert_called_once_with(365, 1) + mock_pydrawise.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "switch.zone_one_automatic_watering"}, + blocking=True, + ) + mock_pydrawise.suspend_zone.assert_called_once_with(0, 1) diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py index 736bc6346ce..d6c2ba5ad6b 100644 --- a/tests/components/idasen_desk/conftest.py +++ b/tests/components/idasen_desk/conftest.py @@ -10,6 +10,10 @@ import pytest @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth): """Auto mock bluetooth.""" + with mock.patch( + "homeassistant.components.idasen_desk.bluetooth.async_ble_device_from_address" + ): + yield MagicMock() @pytest.fixture(autouse=False) @@ -18,14 +22,22 @@ def mock_desk_api(): with mock.patch("homeassistant.components.idasen_desk.Desk") as desk_patched: mock_desk = MagicMock() - def mock_init(update_callback: Callable[[int | None], None] | None): + def mock_init( + update_callback: Callable[[int | None], None] | None, + monitor_height: bool = True, + ): mock_desk.trigger_update_callback = update_callback return mock_desk desk_patched.side_effect = mock_init - async def mock_connect(ble_device, monitor_height: bool = True): + async def mock_connect(ble_device): mock_desk.is_connected = True + mock_desk.trigger_update_callback(None) + + async def mock_disconnect(): + mock_desk.is_connected = False + mock_desk.trigger_update_callback(None) async def mock_move_to(height: float): mock_desk.height_percent = height @@ -38,12 +50,13 @@ def mock_desk_api(): await mock_move_to(0) mock_desk.connect = AsyncMock(side_effect=mock_connect) - mock_desk.disconnect = AsyncMock() + mock_desk.disconnect = AsyncMock(side_effect=mock_disconnect) mock_desk.move_to = AsyncMock(side_effect=mock_move_to) mock_desk.move_up = AsyncMock(side_effect=mock_move_up) mock_desk.move_down = AsyncMock(side_effect=mock_move_down) mock_desk.stop = AsyncMock() mock_desk.height_percent = 60 mock_desk.is_moving = False + mock_desk.address = "AA:BB:CC:DD:EE:FF" yield mock_desk diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py index 223ecc55e28..ca585c65e4d 100644 --- a/tests/components/idasen_desk/test_config_flow.py +++ b/tests/components/idasen_desk/test_config_flow.py @@ -1,8 +1,8 @@ """Test the IKEA Idasen Desk config flow.""" -from unittest.mock import patch +from unittest.mock import ANY, patch -from bleak import BleakError -from idasen_ha import AuthFailedError +from bleak.exc import BleakError +from idasen_ha.errors import AuthFailedError import pytest from homeassistant import config_entries @@ -260,7 +260,9 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect" + ) as desk_connect, patch( "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" ), patch( "homeassistant.components.idasen_desk.async_setup_entry", @@ -281,3 +283,4 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: } assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 + desk_connect.assert_called_with(ANY, auto_reconnect=False) diff --git a/tests/components/idasen_desk/test_init.py b/tests/components/idasen_desk/test_init.py index e596f0fe000..cc8daaf98ea 100644 --- a/tests/components/idasen_desk/test_init.py +++ b/tests/components/idasen_desk/test_init.py @@ -1,7 +1,9 @@ """Test the IKEA Idasen Desk init.""" +from unittest import mock from unittest.mock import AsyncMock, MagicMock -from bleak import BleakError +from bleak.exc import BleakError +from idasen_ha.errors import AuthFailedError import pytest from homeassistant.components.idasen_desk.const import DOMAIN @@ -28,7 +30,7 @@ async def test_setup_and_shutdown( mock_desk_api.disconnect.assert_called_once() -@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +@pytest.mark.parametrize("exception", [AuthFailedError(), TimeoutError(), BleakError()]) async def test_setup_connect_exception( hass: HomeAssistant, mock_desk_api: MagicMock, exception: Exception ) -> None: @@ -39,6 +41,17 @@ async def test_setup_connect_exception( assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_no_ble_device(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: + """Test setup with no BLEDevice from address.""" + with mock.patch( + "homeassistant.components.idasen_desk.bluetooth.async_ble_device_from_address", + return_value=None, + ): + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_RETRY + + async def test_unload_entry(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: """Test successful unload of entry.""" entry = await init_integration(hass) diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index efb505cda77..d36cffbce06 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -469,73 +469,6 @@ async def test_advanced_options_form( assert assert_result == data_entry_flow.FlowResultType.FORM -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - with patch( - "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"]) @pytest.mark.parametrize("verify_ssl", [False, True]) async def test_config_flow_with_cipherlist_and_ssl_verify( diff --git a/tests/components/imap_email_content/__init__.py b/tests/components/imap_email_content/__init__.py deleted file mode 100644 index 2c7e5692366..00000000000 --- a/tests/components/imap_email_content/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the imap_email_content component.""" diff --git a/tests/components/imap_email_content/test_repairs.py b/tests/components/imap_email_content/test_repairs.py deleted file mode 100644 index 6323dcde377..00000000000 --- a/tests/components/imap_email_content/test_repairs.py +++ /dev/null @@ -1,296 +0,0 @@ -"""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 deleted file mode 100644 index 3e8a6c1e282..00000000000 --- a/tests/components/imap_email_content/test_sensor.py +++ /dev/null @@ -1,253 +0,0 @@ -"""The tests for the IMAP email content sensor platform.""" -from collections import deque -import datetime -import email -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText - -from homeassistant.components.imap_email_content import sensor as imap_email_content -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: - """A test class for sending test emails.""" - - def __init__(self, messages) -> None: - """Set up the fake email reader.""" - self._messages = messages - self.last_id = 0 - self.last_unread_id = len(messages) - - def add_test_message(self, message): - """Add a new message.""" - self.last_unread_id += 1 - self._messages.append(message) - - def connect(self): - """Stay always Connected.""" - return True - - def read_next(self): - """Get the next email.""" - if len(self._messages) == 0: - return None - self.last_id += 1 - 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() - test_message["From"] = "sender@test.com" - test_message["Subject"] = "Test" - test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message.set_payload("Test Message") - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Test" - assert sensor.extra_state_attributes["body"] == "Test Message" - assert sensor.extra_state_attributes["from"] == "sender@test.com" - assert sensor.extra_state_attributes["subject"] == "Test" - assert ( - datetime.datetime(2016, 1, 1, 12, 44, 57) - == sensor.extra_state_attributes["date"] - ) - - -async def test_multi_part_with_text(hass: HomeAssistant) -> None: - """Test multi part emails.""" - msg = MIMEMultipart("alternative") - msg["Subject"] = "Link" - msg["From"] = "sender@test.com" - - text = "Test Message" - html = "Test Message" - - textPart = MIMEText(text, "plain") - htmlPart = MIMEText(html, "html") - - msg.attach(textPart) - msg.attach(htmlPart) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([msg])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Link" - assert sensor.extra_state_attributes["body"] == "Test Message" - - -async def test_multi_part_only_html(hass: HomeAssistant) -> None: - """Test multi part emails with only HTML.""" - msg = MIMEMultipart("alternative") - msg["Subject"] = "Link" - msg["From"] = "sender@test.com" - - html = "Test Message" - - htmlPart = MIMEText(html, "html") - - msg.attach(htmlPart) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([msg])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Link" - assert ( - sensor.extra_state_attributes["body"] - == "Test Message" - ) - - -async def test_multi_part_only_other_text(hass: HomeAssistant) -> None: - """Test multi part emails with only other text.""" - msg = MIMEMultipart("alternative") - msg["Subject"] = "Link" - msg["From"] = "sender@test.com" - - other = "Test Message" - - htmlPart = MIMEText(other, "other") - - msg.attach(htmlPart) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([msg])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Link" - assert sensor.extra_state_attributes["body"] == "Test Message" - - -async def test_multiple_emails(hass: HomeAssistant) -> None: - """Test multiple emails, discarding stale states.""" - states = [] - - test_message1 = email.message.Message() - test_message1["From"] = "sender@test.com" - test_message1["Subject"] = "Test" - test_message1["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message1.set_payload("Test Message") - - test_message2 = email.message.Message() - test_message2["From"] = "sender@test.com" - test_message2["Subject"] = "Test 2" - test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 58) - test_message2.set_payload("Test Message 2") - - test_message3 = email.message.Message() - test_message3["From"] = "sender@test.com" - test_message3["Subject"] = "Test 3" - test_message3["Date"] = datetime.datetime(2016, 1, 1, 12, 50, 1) - test_message3.set_payload("Test Message 2") - - def state_changed_listener(entity_id, from_s, to_s): - states.append(to_s) - - async_track_state_change(hass, ["sensor.emailtest"], state_changed_listener) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message1, test_message2])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - # Fake a new received message - sensor._email_reader.add_test_message(test_message3) - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - - assert states[0].state == "Test 2" - assert states[1].state == "Test 3" - - assert sensor.extra_state_attributes["body"] == "Test Message 2" - - -async def test_sender_not_allowed(hass: HomeAssistant) -> None: - """Test not whitelisted emails.""" - test_message = email.message.Message() - test_message["From"] = "sender@test.com" - test_message["Subject"] = "Test" - test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message.set_payload("Test Message") - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message])), - "test_emails_sensor", - ["other@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state is None - - -async def test_template(hass: HomeAssistant) -> None: - """Test value template.""" - test_message = email.message.Message() - test_message["From"] = "sender@test.com" - test_message["Subject"] = "Test" - test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message.set_payload("Test Message") - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message])), - "test_emails_sensor", - ["sender@test.com"], - Template("{{ subject }} from {{ from }} with message {{ body }}", hass), - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Test from sender@test.com with message Test Message" diff --git a/tests/components/improv_ble/__init__.py b/tests/components/improv_ble/__init__.py new file mode 100644 index 00000000000..f1c83bbc0d7 --- /dev/null +++ b/tests/components/improv_ble/__init__.py @@ -0,0 +1,60 @@ +"""Tests for the Improv via BLE integration.""" + +from improv_ble_client import SERVICE_DATA_UUID, SERVICE_UUID + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + +IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, +) + + +PROVISIONED_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x04\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x04\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, +) + + +NOT_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Not", + address="AA:BB:CC:DD:EE:F2", + rssi=-60, + manufacturer_data={ + 33: b"\x00\x00\xd1\xf0b;\xd8\x1dE\xd6\xba\xeeL\xdd]\xf5\xb2\xe9", + 21: b"\x061\x00Z\x8f\x93\xb2\xec\x85\x06\x00i\x00\x02\x02Q\xed\x1d\xf0", + }, + service_uuids=[], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F2", name="Aug"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) diff --git a/tests/components/improv_ble/conftest.py b/tests/components/improv_ble/conftest.py new file mode 100644 index 00000000000..ea548efeb15 --- /dev/null +++ b/tests/components/improv_ble/conftest.py @@ -0,0 +1,8 @@ +"""Improv via BLE test fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py new file mode 100644 index 00000000000..f0c77c9bce3 --- /dev/null +++ b/tests/components/improv_ble/test_config_flow.py @@ -0,0 +1,647 @@ +"""Test the Improv via BLE config flow.""" +from collections.abc import Callable +from unittest.mock import patch + +from bleak.exc import BleakError +from improv_ble_client import Error, State, errors as improv_ble_errors +import pytest + +from homeassistant import config_entries +from homeassistant.components.bluetooth import BluetoothChange +from homeassistant.components.improv_ble.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from . import ( + IMPROV_BLE_DISCOVERY_INFO, + NOT_IMPROV_BLE_DISCOVERY_INFO, + PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, +) + +IMPROV_BLE = "homeassistant.components.improv_ble" + + +@pytest.mark.parametrize( + ("url", "abort_reason", "placeholders"), + [ + ("http://bla.local", "provision_successful_url", {"url": "http://bla.local"}), + (None, "provision_successful", None), + ], +) +async def test_user_step_success( + hass: HomeAssistant, + url: str | None, + abort_reason: str | None, + placeholders: dict[str, str] | None, +) -> None: + """Test user step success path.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_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" + assert result["errors"] == {} + + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address, url, abort_reason, placeholders + ) + + +async def test_user_step_success_authorize(hass: HomeAssistant) -> None: + """Test user step success path.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_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" + assert result["errors"] == {} + + await _test_common_success_wo_identify_w_authorize( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + +async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: + """Test user step with no devices found.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[ + PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, + NOT_IMPROV_BLE_DISCOVERY_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_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=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[IMPROV_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + # Verify the discovery flow was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def test_bluetooth_step_provisioned_device(hass: HomeAssistant) -> None: + """Test bluetooth step when device is already provisioned.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_provisioned" + + +async def test_bluetooth_step_provisioned_device_2(hass: HomeAssistant) -> None: + """Test bluetooth step when device changes to provisioned.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_register_callback", + ) as mock_async_register_callback: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + assert len(hass.config_entries.flow.async_progress_by_handler("improv_ble")) == 1 + + callback = mock_async_register_callback.call_args.args[1] + callback(PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, BluetoothChange.ADVERTISEMENT) + + assert len(hass.config_entries.flow.async_progress_by_handler("improv_ble")) == 0 + + +async def test_bluetooth_step_success(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + +async def test_bluetooth_step_success_identify(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + await _test_common_success_with_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + +async def _test_common_success_with_identify( + hass: HomeAssistant, result: FlowResult, address: str +) -> None: + """Test bluetooth and user flow success paths.""" + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: address}, + ) + assert result["type"] == FlowResultType.MENU + assert result["menu_options"] == ["identify", "provision"] + assert result["step_id"] == "main_menu" + + with patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.identify"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "identify"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "identify" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.MENU + assert result["menu_options"] == ["identify", "provision"] + assert result["step_id"] == "main_menu" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "provision"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] is None + + await _test_common_success(hass, result) + + +async def _test_common_success_wo_identify( + hass: HomeAssistant, + result: FlowResult, + address: str, + url: str | None = None, + abort_reason: str = "provision_successful", + placeholders: dict[str, str] | None = None, +) -> None: + """Test bluetooth and user flow success paths.""" + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] is None + + await _test_common_success(hass, result) + + +async def _test_common_success( + hass: HomeAssistant, + result: FlowResult, + url: str | None = None, + abort_reason: str = "provision_successful", + placeholders: dict[str, str] | None = None, +) -> None: + """Test bluetooth and user flow success paths.""" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value=url, + ) as mock_provision: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("description_placeholders") == placeholders + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == abort_reason + + mock_provision.assert_awaited_once_with("MyWIFI", "secret", None) + + +async def _test_common_success_wo_identify_w_authorize( + hass: HomeAssistant, result: FlowResult, address: str +) -> None: + """Test bluetooth and user flow success paths.""" + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] is None + + await _test_common_success_w_authorize(hass, result) + + +async def _test_common_success_w_authorize( + hass: HomeAssistant, result: FlowResult +) -> None: + """Test bluetooth and user flow success paths.""" + + async def subscribe_state_updates( + state_callback: Callable[[State], None] + ) -> Callable[[], None]: + state_callback(State.AUTHORIZED) + return lambda: None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=True, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", + side_effect=subscribe_state_updates, + ) as mock_subscribe_state_updates: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "authorize" + assert result["step_id"] == "authorize" + mock_subscribe_state_updates.assert_awaited_once() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value="http://blabla.local", + ) as mock_provision: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["description_placeholders"] == {"url": "http://blabla.local"} + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "provision_successful_url" + + mock_provision.assert_awaited_once_with("MyWIFI", "secret", None) + + +async def test_bluetooth_step_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=IMPROV_BLE_DISCOVERY_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=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_can_identify_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", side_effect=exc + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_identify_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + with patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.identify", side_effect=exc): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "identify"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_need_authorization_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", side_effect=exc + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_authorize_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=True, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +async def _test_provision_error(hass: HomeAssistant, exc) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision_done" + + return result["flow_id"] + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + (improv_ble_errors.ProvisioningFailed(Error.UNKNOWN_ERROR), "unknown"), + ), +) +async def test_provision_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + flow_id = await _test_provision_error(hass, exc) + + result = await hass.config_entries.flow.async_configure(flow_id) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ((improv_ble_errors.ProvisioningFailed(Error.NOT_AUTHORIZED), "unknown"),), +) +async def test_provision_not_authorized(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + + async def subscribe_state_updates( + state_callback: Callable[[State], None] + ) -> Callable[[], None]: + state_callback(State.AUTHORIZED) + return lambda: None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", + side_effect=subscribe_state_updates, + ): + flow_id = await _test_provision_error(hass, exc) + result = await hass.config_entries.flow.async_configure(flow_id) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "authorize" + assert result["step_id"] == "authorize" + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + ( + improv_ble_errors.ProvisioningFailed(Error.UNABLE_TO_CONNECT), + "unable_to_connect", + ), + ), +) +async def test_provision_retry(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + flow_id = await _test_provision_error(hass, exc) + + result = await hass.config_entries.flow.async_configure(flow_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] == {"base": error} diff --git a/tests/components/integration/test_config_flow.py b/tests/components/integration/test_config_flow.py index aedfbc6e8bc..c92cf70b0c2 100644 --- a/tests/components/integration/test_config_flow.py +++ b/tests/components/integration/test_config_flow.py @@ -33,7 +33,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "My integration", "round": 1, "source": input_sensor_entity_id, - "unit_prefix": "none", "unit_time": "min", }, ) @@ -47,7 +46,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "My integration", "round": 1.0, "source": "sensor.input", - "unit_prefix": "none", "unit_time": "min", } assert len(mock_setup_entry.mock_calls) == 1 @@ -59,7 +57,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "My integration", "round": 1.0, "source": "sensor.input", - "unit_prefix": "none", "unit_time": "min", } assert config_entry.title == "My integration" diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index a68b2a9be24..fe694607def 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -95,6 +95,38 @@ async def test_intent_script_wait_response(hass: HomeAssistant) -> None: assert response.card["simple"]["content"] == "Content for Paulus" +async def test_intent_script_service_response(hass: HomeAssistant) -> None: + """Test intent scripts work.""" + calls = async_mock_service( + hass, "test", "service", response={"some_key": "some value"} + ) + + await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "HelloWorldServiceResponse": { + "action": [ + {"service": "test.service", "response_variable": "result"}, + {"stop": "", "response_variable": "result"}, + ], + "speech": { + "text": "The service returned {{ action_response.some_key }}" + }, + } + } + }, + ) + + response = await intent.async_handle(hass, "test", "HelloWorldServiceResponse") + + assert len(calls) == 1 + assert calls[0].return_response + + assert response.speech["plain"]["speech"] == "The service returned some value" + + async def test_intent_script_falsy_reprompt(hass: HomeAssistant) -> None: """Test intent scripts work.""" calls = async_mock_service(hass, "test", "service") diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index b1cf8f2c9a5..6b3b112e042 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -9,7 +9,9 @@ import pytest from homeassistant import config_entries from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import ( NEW_PRAYER_TIMES, @@ -145,3 +147,46 @@ async def test_update(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() assert pt_data.data == NEW_PRAYER_TIMES_TIMESTAMPS + + +@pytest.mark.parametrize( + ("object_id", "old_unique_id"), + [ + ( + "fajer_prayer", + "Fajr", + ), + ( + "dhuhr_prayer", + "Dhuhr", + ), + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, object_id: str, old_unique_id: str +) -> None: + """Test unique id migration.""" + entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry.add_to_hass(hass) + + ent_reg = er.async_get(hass) + + entity: er.RegistryEntry = ent_reg.async_get_or_create( + suggested_object_id=object_id, + domain=SENSOR_DOMAIN, + platform=islamic_prayer_times.DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ), freeze_time(NOW): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = ent_reg.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}-{old_unique_id}" diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index a5b9b9c8a8d..e7f3759f993 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -44,7 +44,6 @@ async def test_islamic_prayer_times_sensors( ), freeze_time(NOW): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert ( hass.states.get(sensor_name).state == PRAYER_TIMES_TIMESTAMPS[key].astimezone(dt_util.UTC).isoformat() diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py index 4953824a1c5..157c25b4af4 100644 --- a/tests/components/jellyfin/const.py +++ b/tests/components/jellyfin/const.py @@ -2,6 +2,18 @@ from typing import Final +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + TEST_URL: Final = "https://example.com" TEST_USERNAME: Final = "test-username" TEST_PASSWORD: Final = "test-password" + +USER_INPUT: Final = { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, +} + +REAUTH_INPUT: Final = { + CONF_PASSWORD: TEST_PASSWORD, +} diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index 51aa4bccc92..c59efd7efb9 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from . import async_load_json_fixture -from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME +from .const import REAUTH_INPUT, TEST_PASSWORD, TEST_URL, TEST_USERNAME, USER_INPUT from tests.common import MockConfigEntry @@ -44,11 +44,7 @@ async def test_form( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -73,7 +69,7 @@ async def test_form_cannot_connect( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test we handle an unreachable server.""" + """Test configuration with an unreachable server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -86,11 +82,7 @@ async def test_form_cannot_connect( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -106,7 +98,7 @@ async def test_form_invalid_auth( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test that we can handle invalid credentials.""" + """Test configuration with invalid credentials.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -119,11 +111,7 @@ async def test_form_invalid_auth( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -137,7 +125,7 @@ async def test_form_invalid_auth( async def test_form_exception( hass: HomeAssistant, mock_jellyfin: MagicMock, mock_client: MagicMock ) -> None: - """Test we handle an unexpected exception during server setup.""" + """Test configuration with an unexpected exception.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -148,11 +136,7 @@ async def test_form_exception( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -168,7 +152,7 @@ async def test_form_persists_device_id_on_error( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test that we can handle invalid credentials.""" + """Test persisting the device id on error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -182,11 +166,7 @@ async def test_form_persists_device_id_on_error( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -200,11 +180,7 @@ async def test_form_persists_device_id_on_error( result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -216,3 +192,244 @@ async def test_form_persists_device_id_on_error( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, } + + +async def test_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Complete the reauth + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test an unreachable server during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform reauth with unreachable server + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + # Complete reauth with reachable server + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address.json" + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +async def test_reauth_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test invalid credentials during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform reauth with invalid credentials + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + assert len(mock_client.auth.login.mock_calls) == 1 + + # Complete reauth with valid credentials + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +async def test_reauth_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test an unexpected exception during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform a reauth with an unknown exception + mock_client.auth.connect_to_address.side_effect = Exception("UnknownException") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + # Complete the reauth without an exception + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + mock_client.auth.connect_to_address.side_effect = None + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/jellyfin/test_init.py b/tests/components/jellyfin/test_init.py index 9af73391d18..eb184592bb8 100644 --- a/tests/components/jellyfin/test_init.py +++ b/tests/components/jellyfin/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock from homeassistant.components.jellyfin.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -67,6 +67,10 @@ async def test_invalid_auth( mock_config_entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + async def test_load_unload_config_entry( hass: HomeAssistant, diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index f8200214019..5d42ed79542 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -302,7 +302,7 @@ async def test_routing_secure_manual_setup( }, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_routing" result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], @@ -392,7 +392,7 @@ async def test_routing_secure_keyfile( }, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_routing" result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], @@ -948,7 +948,7 @@ async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: {CONF_KNX_GATEWAY: str(gateway)}, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_tunnel" return result3 @@ -1008,7 +1008,7 @@ async def test_get_secure_menu_step_manual_tunnelling( }, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_tunnel" async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: @@ -1272,7 +1272,7 @@ async def test_options_flow_secure_manual_to_keyfile( {CONF_KNX_GATEWAY: str(gateway)}, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_tunnel" result4 = await hass.config_entries.options.async_configure( result3["flow_id"], diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index 3181f978112..4dbfe3abb57 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -88,7 +88,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.id, "type": "turn_on", }, @@ -105,7 +105,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.id, "type": "turn_off", }, @@ -161,7 +161,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.entity_id, "type": "turn_on", }, diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index 8efac3017e0..5ef913ab74b 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.kraken.const import ( ) from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from .const import ( MISSING_PAIR_TICKER_INFORMATION_RESPONSE, @@ -25,7 +25,11 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_sensor(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: +async def test_sensor( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, +) -> None: """Test that sensor has a value.""" with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", @@ -51,105 +55,6 @@ async def test_sensor(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> No ) entry.add_to_hass(hass) - registry = er.async_get(hass) - - # Pre-create registry entries for disabled by default sensors - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_ask_volume", - suggested_object_id="xbt_usd_ask_volume", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_last_trade_closed", - suggested_object_id="xbt_usd_last_trade_closed", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_bid_volume", - suggested_object_id="xbt_usd_bid_volume", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_volume_today", - suggested_object_id="xbt_usd_volume_today", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_volume_last_24h", - suggested_object_id="xbt_usd_volume_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_volume_weighted_average_today", - suggested_object_id="xbt_usd_volume_weighted_average_today", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_volume_weighted_average_last_24h", - suggested_object_id="xbt_usd_volume_weighted_average_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_number_of_trades_today", - suggested_object_id="xbt_usd_number_of_trades_today", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_number_of_trades_last_24h", - suggested_object_id="xbt_usd_number_of_trades_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_low_last_24h", - suggested_object_id="xbt_usd_low_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_high_last_24h", - suggested_object_id="xbt_usd_high_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_opening_price_today", - suggested_object_id="xbt_usd_opening_price_today", - disabled_by=None, - ) - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index 66c9e3d2147..b9cad7c5f9c 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -71,6 +71,12 @@ async def test_init(hass: HomeAssistant, mock_light) -> None: ATTR_FRIENDLY_NAME: "Bedroom", ATTR_SUPPORTED_COLOR_MODES: [ColorMode.RGBW], ATTR_SUPPORTED_FEATURES: 0, + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_XY_COLOR: None, + ATTR_RGBW_COLOR: None, } with patch.object(hass.loop, "stop"): @@ -191,6 +197,12 @@ async def test_light_update(hass: HomeAssistant, mock_light) -> None: ATTR_FRIENDLY_NAME: "Bedroom", ATTR_SUPPORTED_COLOR_MODES: [ColorMode.RGBW], ATTR_SUPPORTED_FEATURES: 0, + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_RGBW_COLOR: None, + ATTR_XY_COLOR: None, } # Test an exception during discovery diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..cadd0e37566 --- /dev/null +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'audio': dict({ + 'volume': 100, + 'volume_limit': dict({ + 'range_max': 100, + 'range_min': 0, + }), + 'volume_range': dict({ + 'range_max': 100, + 'range_min': 0, + }), + }), + 'bluetooth': dict({ + 'active': False, + 'address': 'AA:BB:CC:DD:EE:FF', + 'available': True, + 'discoverable': True, + 'name': '**REDACTED**', + 'pairable': True, + }), + 'device_id': '**REDACTED**', + 'display': dict({ + 'brightness': 100, + 'brightness_mode': 'auto', + 'display_type': 'mixed', + 'height': 8, + 'width': 37, + }), + 'mode': 'auto', + 'model': 'LM 37X8', + 'name': '**REDACTED**', + 'os_version': '2.2.2', + 'serial_number': '**REDACTED**', + 'wifi': dict({ + 'active': True, + 'available': True, + 'encryption': 'WPA', + 'ip': '127.0.0.1', + 'mac': 'AA:BB:CC:DD:EE:FF', + 'mode': 'dhcp', + 'netmask': '255.255.255.0', + 'rssi': 21, + 'ssid': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/lametric/test_diagnostics.py b/tests/components/lametric/test_diagnostics.py index e36d38b91e3..333985f71a0 100644 --- a/tests/components/lametric/test_diagnostics.py +++ b/tests/components/lametric/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the LaMetric integration.""" +from syrupy import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,46 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "device_id": REDACTED, - "name": REDACTED, - "serial_number": REDACTED, - "os_version": "2.2.2", - "mode": "auto", - "model": "LM 37X8", - "audio": { - "volume": 100, - "volume_range": {"range_min": 0, "range_max": 100}, - "volume_limit": {"range_min": 0, "range_max": 100}, - }, - "bluetooth": { - "available": True, - "name": REDACTED, - "active": False, - "discoverable": True, - "pairable": True, - "address": "AA:BB:CC:DD:EE:FF", - }, - "display": { - "brightness": 100, - "brightness_mode": "auto", - "width": 37, - "height": 8, - "display_type": "mixed", - }, - "wifi": { - "active": True, - "mac": "AA:BB:CC:DD:EE:FF", - "available": True, - "encryption": "WPA", - "ssid": REDACTED, - "ip": "127.0.0.1", - "mode": "dhcp", - "netmask": "255.255.255.0", - "rssi": 21, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 05483b46d97..3b60b886b02 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -467,12 +467,21 @@ async def test_get_action_capabilities_features_legacy( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -483,7 +492,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -492,7 +501,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -501,7 +510,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -510,7 +519,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_flash_short"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "flash", }, @@ -519,7 +528,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_flash_long"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "flash", "flash": "long", @@ -532,7 +541,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "brightness_increase", }, @@ -544,7 +553,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "brightness_decrease", }, @@ -553,7 +562,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_brightness"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", "brightness_pct": 75, @@ -623,12 +632,21 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -639,7 +657,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index b38c225347a..000784ce63c 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -179,12 +179,21 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -199,7 +208,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -218,7 +227,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -253,12 +262,21 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -273,7 +291,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } @@ -301,12 +319,21 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for firing if condition is on with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -333,7 +360,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 085193e3b34..5ee6752640e 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -177,12 +177,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -195,7 +204,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -219,7 +228,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -243,7 +252,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -288,12 +297,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -306,7 +324,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -342,12 +360,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -360,7 +387,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 2dc8e504898..675057899b0 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1151,28 +1151,28 @@ async def test_light_backwards_compatibility_supported_color_modes( state = hass.states.get(entity0.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.ONOFF state = hass.states.get(entity1.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN state = hass.states.get(entity2.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN state = hass.states.get(entity3.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN @@ -1182,7 +1182,7 @@ async def test_light_backwards_compatibility_supported_color_modes( light.ColorMode.HS, ] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN @@ -1285,6 +1285,79 @@ async def test_light_service_call_rgbw( assert data == {"brightness": 255, "rgbw_color": (10, 20, 30, 40)} +async def test_light_state_off( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test rgbw color conversion in state updates.""" + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + platform.ENTITIES.append(platform.MockLight("Test_onoff", STATE_OFF)) + platform.ENTITIES.append(platform.MockLight("Test_brightness", STATE_OFF)) + platform.ENTITIES.append(platform.MockLight("Test_ct", STATE_OFF)) + platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_OFF)) + + entity0 = platform.ENTITIES[0] + entity0.supported_color_modes = {light.ColorMode.ONOFF} + entity1 = platform.ENTITIES[1] + entity1.supported_color_modes = {light.ColorMode.BRIGHTNESS} + entity2 = platform.ENTITIES[2] + entity2.supported_color_modes = {light.ColorMode.COLOR_TEMP} + entity3 = platform.ENTITIES[3] + entity3.supported_color_modes = {light.ColorMode.RGBW} + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes == { + "color_mode": None, + "friendly_name": "Test_onoff", + "supported_color_modes": [light.ColorMode.ONOFF], + "supported_features": 0, + } + + state = hass.states.get(entity1.entity_id) + assert state.attributes == { + "color_mode": None, + "friendly_name": "Test_brightness", + "supported_color_modes": [light.ColorMode.BRIGHTNESS], + "supported_features": 0, + "brightness": None, + } + + state = hass.states.get(entity2.entity_id) + assert state.attributes == { + "color_mode": None, + "friendly_name": "Test_ct", + "supported_color_modes": [light.ColorMode.COLOR_TEMP], + "supported_features": 0, + "brightness": None, + "color_temp": None, + "color_temp_kelvin": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + "max_color_temp_kelvin": 6500, + "max_mireds": 500, + "min_color_temp_kelvin": 2000, + "min_mireds": 153, + } + + state = hass.states.get(entity3.entity_id) + assert state.attributes == { + "color_mode": None, + "friendly_name": "Test_rgbw", + "supported_color_modes": [light.ColorMode.RGBW], + "supported_features": 0, + "brightness": None, + "rgbw_color": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + } + + async def test_light_state_rgbw( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -1295,6 +1368,7 @@ async def test_light_state_rgbw( platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_ON)) entity0 = platform.ENTITIES[0] + entity0.brightness = 255 entity0.supported_color_modes = {light.ColorMode.RGBW} entity0.color_mode = light.ColorMode.RGBW entity0.hs_color = "Invalid" # Should be ignored @@ -1316,6 +1390,7 @@ async def test_light_state_rgbw( "rgb_color": (3, 3, 4), "rgbw_color": (1, 2, 3, 4), "xy_color": (0.301, 0.295), + "brightness": 255, } @@ -1336,12 +1411,13 @@ async def test_light_state_rgbww( entity0.rgbw_color = "Invalid" # Should be ignored entity0.rgbww_color = (1, 2, 3, 4, 5) entity0.xy_color = "Invalid" # Should be ignored + entity0.brightness = 255 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert dict(state.attributes) == { + assert state.attributes == { "color_mode": light.ColorMode.RGBWW, "friendly_name": "Test_rgbww", "supported_color_modes": [light.ColorMode.RGBWW], @@ -1350,6 +1426,7 @@ async def test_light_state_rgbww( "rgb_color": (5, 5, 4), "rgbww_color": (1, 2, 3, 4, 5), "xy_color": (0.339, 0.354), + "brightness": 255, } diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index edf691b6099..1376ee53649 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components import light from homeassistant.components.light import ( - ATTR_EFFECT, + ATTR_EFFECT_LIST, ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_MAX_MIREDS, ATTR_MIN_COLOR_TEMP_KELVIN, @@ -57,7 +57,7 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) assert ATTR_MIN_MIREDS not in state.attributes assert ATTR_MAX_MIREDS not in state.attributes assert ATTR_SUPPORTED_COLOR_MODES not in state.attributes - assert ATTR_EFFECT not in state.attributes + assert ATTR_EFFECT_LIST not in state.attributes assert ATTR_FRIENDLY_NAME in state.attributes assert ATTR_MAX_COLOR_TEMP_KELVIN not in state.attributes assert ATTR_MIN_COLOR_TEMP_KELVIN not in state.attributes diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index f36b8180560..816bde430e7 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -22,6 +22,20 @@ VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} VALID_RGBWW_COLOR = {"rgbww_color": (255, 63, 111, 10, 20)} VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} +NONE_BRIGHTNESS = {"brightness": None} +NONE_FLASH = {"flash": None} +NONE_EFFECT = {"effect": None} +NONE_TRANSITION = {"transition": None} +NONE_COLOR_NAME = {"color_name": None} +NONE_COLOR_TEMP = {"color_temp": None} +NONE_HS_COLOR = {"hs_color": None} +NONE_KELVIN = {"kelvin": None} +NONE_PROFILE = {"profile": None} +NONE_RGB_COLOR = {"rgb_color": None} +NONE_RGBW_COLOR = {"rgbw_color": None} +NONE_RGBWW_COLOR = {"rgbww_color": None} +NONE_XY_COLOR = {"xy_color": None} + async def test_reproducing_states( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -237,3 +251,39 @@ async def test_deprecation_warning( ) assert len(turn_on_calls) == 1 assert DEPRECATION_WARNING % ["brightness_pct"] in caplog.text + + +@pytest.mark.parametrize( + "saved_state", + ( + NONE_BRIGHTNESS, + NONE_FLASH, + NONE_EFFECT, + NONE_TRANSITION, + NONE_COLOR_NAME, + NONE_COLOR_TEMP, + NONE_HS_COLOR, + NONE_KELVIN, + NONE_PROFILE, + NONE_RGB_COLOR, + NONE_RGBW_COLOR, + NONE_RGBWW_COLOR, + NONE_XY_COLOR, + ), +) +async def test_filter_none(hass: HomeAssistant, saved_state) -> None: + """Test filtering of parameters which are None.""" + hass.states.async_set("light.entity", "off", {}) + + turn_on_calls = async_mock_service(hass, "light", "turn_on") + + await async_reproduce_state(hass, [State("light.entity", "on", saved_state)]) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "light" + assert dict(turn_on_calls[0].data) == {"entity_id": "light.entity"} + + # This should do nothing, the light is already in the desired state + hass.states.async_set("light.entity", "on", {}) + await async_reproduce_state(hass, [State("light.entity", "on", saved_state)]) + assert len(turn_on_calls) == 1 diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 655f58fa94f..3aee7b5075f 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -12,6 +12,7 @@ from homeassistant.components.vacuum import ( ATTR_STATUS, DOMAIN as PLATFORM_DOMAIN, SERVICE_START, + SERVICE_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_DOCKED, @@ -19,7 +20,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from .common import VACUUM_ENTITY_ID from .conftest import setup_integration @@ -98,8 +99,17 @@ async def test_vacuum_with_error( ("service", "command", "extra"), [ (SERVICE_START, "start_cleaning", None), - (SERVICE_TURN_OFF, "set_power_status", None), - (SERVICE_TURN_ON, "set_power_status", None), + (SERVICE_STOP, "set_power_status", None), + ( + SERVICE_TURN_OFF, + "set_power_status", + {"issues": {(DOMAIN, "service_deprecation_turn_off")}}, + ), + ( + SERVICE_TURN_ON, + "set_power_status", + {"issues": {(DOMAIN, "service_deprecation_turn_on")}}, + ), ( SERVICE_SET_SLEEP_MODE, "set_sleep_mode", @@ -126,7 +136,7 @@ async def test_commands( extra = extra or {} data = {ATTR_ENTITY_ID: VACUUM_ENTITY_ID, **extra.get("data", {})} - deprecated = extra.get("deprecated", False) + issues = extra.get("issues", set()) await hass.services.async_call( COMPONENT_SERVICE_DOMAIN.get(service, PLATFORM_DOMAIN), @@ -135,4 +145,56 @@ async def test_commands( blocking=True, ) getattr(mock_account.robots[0], command).assert_called_once() - assert (f"'{DOMAIN}.{service}' service is deprecated" in caplog.text) is deprecated + + issue_registry = ir.async_get(hass) + assert set(issue_registry.issues.keys()) == issues + + +@pytest.mark.parametrize( + ("service", "issue_id", "placeholders"), + [ + ( + SERVICE_TURN_OFF, + "service_deprecation_turn_off", + { + "old_service": "vacuum.turn_off", + "new_service": "vacuum.stop", + }, + ), + ( + SERVICE_TURN_ON, + "service_deprecation_turn_on", + { + "old_service": "vacuum.turn_on", + "new_service": "vacuum.start", + }, + ), + ], +) +async def test_issues( + hass: HomeAssistant, + mock_account: MagicMock, + caplog: pytest.LogCaptureFixture, + service: str, + issue_id: str, + placeholders: dict[str, str], +) -> None: + """Test issues raised by calling deprecated services.""" + await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + + vacuum = hass.states.get(VACUUM_ENTITY_ID) + assert vacuum + assert vacuum.state == STATE_DOCKED + + await hass.services.async_call( + PLATFORM_DOMAIN, + service, + {ATTR_ENTITY_ID: VACUUM_ENTITY_ID}, + blocking=True, + ) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue.is_fixable is True + assert issue.is_persistent is True + assert issue.translation_placeholders == placeholders diff --git a/tests/components/local_todo/__init__.py b/tests/components/local_todo/__init__.py new file mode 100644 index 00000000000..a96a2e85cbd --- /dev/null +++ b/tests/components/local_todo/__init__.py @@ -0,0 +1 @@ +"""Tests for the local_todo integration.""" diff --git a/tests/components/local_todo/conftest.py b/tests/components/local_todo/conftest.py new file mode 100644 index 00000000000..5afa005dd64 --- /dev/null +++ b/tests/components/local_todo/conftest.py @@ -0,0 +1,104 @@ +"""Common fixtures for the local_todo tests.""" +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.local_todo import LocalTodoListStore +from homeassistant.components.local_todo.const import ( + CONF_STORAGE_KEY, + CONF_TODO_LIST_NAME, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TODO_NAME = "My Tasks" +FRIENDLY_NAME = "My tasks" +STORAGE_KEY = "my_tasks" +TEST_ENTITY = "todo.my_tasks" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.local_todo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +class FakeStore(LocalTodoListStore): + """Mock storage implementation.""" + + def __init__( + self, + hass: HomeAssistant, + path: Path, + ics_content: str | None, + read_side_effect: Any | None = None, + ) -> None: + """Initialize FakeStore.""" + mock_path = self._mock_path = Mock() + mock_path.exists = self._mock_exists + mock_path.read_text = Mock() + mock_path.read_text.return_value = ics_content + mock_path.read_text.side_effect = read_side_effect + mock_path.write_text = self._mock_write_text + + super().__init__(hass, mock_path) + + def _mock_exists(self) -> bool: + return self._mock_path.read_text.return_value is not None + + def _mock_write_text(self, content: str) -> None: + self._mock_path.read_text.return_value = content + + +@pytest.fixture(name="ics_content") +def mock_ics_content() -> str | None: + """Fixture to set .ics file content.""" + return "" + + +@pytest.fixture(name="store_read_side_effect") +def mock_store_read_side_effect() -> Any | None: + """Fixture to raise errors from the FakeStore.""" + return None + + +@pytest.fixture(name="store", autouse=True) +def mock_store( + ics_content: str, store_read_side_effect: Any | None +) -> Generator[None, None, None]: + """Fixture that sets up a fake local storage object.""" + + stores: dict[Path, FakeStore] = {} + + def new_store(hass: HomeAssistant, path: Path) -> FakeStore: + if path not in stores: + stores[path] = FakeStore(hass, path, ics_content, store_read_side_effect) + return stores[path] + + with patch("homeassistant.components.local_todo.LocalTodoListStore", new=new_store): + yield + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for mock configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_STORAGE_KEY: STORAGE_KEY, CONF_TODO_LIST_NAME: TODO_NAME}, + ) + + +@pytest.fixture(name="setup_integration") +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/local_todo/test_config_flow.py b/tests/components/local_todo/test_config_flow.py new file mode 100644 index 00000000000..6677a39e54a --- /dev/null +++ b/tests/components/local_todo/test_config_flow.py @@ -0,0 +1,64 @@ +"""Test the local_todo config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.local_todo.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import STORAGE_KEY, TODO_NAME + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "todo_list_name": TODO_NAME, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TODO_NAME + assert result2["data"] == { + "todo_list_name": TODO_NAME, + "storage_key": STORAGE_KEY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_todo_list_name( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test two todo-lists cannot be added with the same name.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + # Pick a name that has the same slugify value as an existing config entry + "todo_list_name": "my tasks", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/local_todo/test_init.py b/tests/components/local_todo/test_init.py new file mode 100644 index 00000000000..98da2ef3c12 --- /dev/null +++ b/tests/components/local_todo/test_init.py @@ -0,0 +1,60 @@ +"""Tests for init platform of local_todo.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY + +from tests.common import MockConfigEntry + + +async def test_load_unload( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test loading and unloading a config entry.""" + + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + 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(TEST_ENTITY) + assert state + assert state.state == "unavailable" + + +async def test_remove_config_entry( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test removing a config entry.""" + + with patch("homeassistant.components.local_todo.Path.unlink") as unlink_mock: + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + unlink_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("store_read_side_effect"), + [ + (OSError("read error")), + ], +) +async def test_load_failure( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test failures loading the todo store.""" + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + state = hass.states.get(TEST_ENTITY) + assert not state diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py new file mode 100644 index 00000000000..39e9264d45a --- /dev/null +++ b/tests/components/local_todo/test_todo.py @@ -0,0 +1,420 @@ +"""Tests for todo platform of local_todo.""" + +from collections.abc import Awaitable, Callable +import textwrap + +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY + +from tests.typing import WebSocketGenerator + + +@pytest.fixture +def ws_req_id() -> Callable[[], int]: + """Fixture for incremental websocket requests.""" + + id = 0 + + def next() -> int: + nonlocal id + id += 1 + return id + + return next + + +@pytest.fixture +async def ws_get_items( + hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + await client.send_json( + { + "id": id, + "type": "todo/item/list", + "entity_id": TEST_ENTITY, + } + ) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get + + +@pytest.fixture +async def ws_move_item( + hass_ws_client: WebSocketGenerator, + ws_req_id: Callable[[], int], +) -> Callable[[str, str | None], Awaitable[None]]: + """Fixture to move an item in the todo list.""" + + async def move(uid: str, previous_uid: str | None) -> None: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + data = { + "id": id, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": uid, + } + if previous_uid is not None: + data["previous_uid"] = previous_uid + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + + return move + + +async def test_add_item( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test adding a todo item.""" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "replace batteries"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "replace batteries" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_remove_item( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test removing a todo item.""" + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "replace batteries"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "replace batteries" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": [items[0]["uid"]]}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_bulk_remove( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test removing multiple todo items.""" + for i in range(0, 5): + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": f"soda #{i}"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 5 + uids = [item["uid"] for item in items] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "5" + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": uids}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_update_item( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Mark item completed + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": item["uid"], "status": "completed"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is marked as completed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +@pytest.mark.parametrize( + ("src_idx", "dst_idx", "expected_items"), + [ + # Move any item to the front of the list + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), + # Move items right + (0, 1, ["item 2", "item 1", "item 3", "item 4"]), + (0, 2, ["item 2", "item 3", "item 1", "item 4"]), + (0, 3, ["item 2", "item 3", "item 4", "item 1"]), + (1, 2, ["item 1", "item 3", "item 2", "item 4"]), + (1, 3, ["item 1", "item 3", "item 4", "item 2"]), + # Move items left + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), + # No-ops + (0, 0, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), + (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 3, ["item 1", "item 2", "item 3", "item 4"]), + ], +) +async def test_move_item( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + ws_move_item: Callable[[str, str | None], Awaitable[None]], + src_idx: int, + dst_idx: int | None, + expected_items: list[str], +) -> None: + """Test moving a todo item within the list.""" + for i in range(1, 5): + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": f"item {i}"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 4 + uids = [item["uid"] for item in items] + summaries = [item["summary"] for item in items] + assert summaries == ["item 1", "item 2", "item 3", "item 4"] + + # Prepare items for moving + previous_uid = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + await ws_move_item(uids[src_idx], previous_uid) + + items = await ws_get_items() + assert len(items) == 4 + summaries = [item["summary"] for item in items] + assert summaries == expected_items + + +async def test_move_item_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test moving a todo item that does not exist.""" + + # Prepare items for moving + client = await hass_ws_client() + data = { + "id": 1, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": "unknown", + "previous_uid": "item-2", + } + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "not found in todo list" in resp["error"]["message"] + + +async def test_move_item_previous_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test moving a todo item that does not exist.""" + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "item 1"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + items = await ws_get_items() + assert len(items) == 1 + + # Prepare items for moving + client = await hass_ws_client() + data = { + "id": 1, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": items[0]["uid"], + "previous_uid": "unknown", + } + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "not found in todo list" in resp["error"]["message"] + + +@pytest.mark.parametrize( + ("ics_content", "expected_state"), + [ + ("", "0"), + (None, "0"), + ( + textwrap.dedent( + """\ + BEGIN:VCALENDAR + PRODID:-//homeassistant.io//local_todo 1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20231024T014011 + UID:077cb7f2-6c89-11ee-b2a9-0242ac110002 + CREATED:20231017T010348 + LAST-MODIFIED:20231024T014011 + SEQUENCE:1 + STATUS:COMPLETED + SUMMARY:Complete Task + END:VTODO + END:VCALENDAR + """ + ), + "0", + ), + ( + textwrap.dedent( + """\ + BEGIN:VCALENDAR + PRODID:-//homeassistant.io//local_todo 1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20231024T014011 + UID:077cb7f2-6c89-11ee-b2a9-0242ac110002 + CREATED:20231017T010348 + LAST-MODIFIED:20231024T014011 + SEQUENCE:1 + STATUS:NEEDS-ACTION + SUMMARY:Incomplete Task + END:VTODO + END:VCALENDAR + """ + ), + "1", + ), + ], + ids=("empty", "not_exists", "completed", "needs_action"), +) +async def test_parse_existing_ics( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_integration: None, + expected_state: str, +) -> None: + """Test parsing ics content.""" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == expected_state diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index f87fa4cc178..1e451920baf 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -136,9 +136,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for lock actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -149,7 +161,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_lock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "lock", }, @@ -158,7 +170,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_unlock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "unlock", }, @@ -167,7 +179,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open", }, @@ -211,10 +223,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for lock actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -225,7 +247,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_event_lock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "lock", }, diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 43513930f2e..59dcbcb4629 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -129,10 +129,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_LOCKED) @@ -147,7 +158,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_locked", } @@ -165,7 +176,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_unlocked", } @@ -183,7 +194,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_unlocking", } @@ -201,7 +212,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_locking", } @@ -219,7 +230,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_jammed", } @@ -267,10 +278,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_LOCKED) @@ -285,7 +307,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_locked", } diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 107e0924440..9c1594760c9 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -185,10 +185,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNLOCKED) @@ -201,7 +212,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "locked", }, @@ -220,7 +231,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "unlocked", }, @@ -259,10 +270,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNLOCKED) @@ -275,7 +297,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "locked", }, @@ -305,10 +327,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNLOCKED) @@ -321,7 +354,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "locked", "for": {"seconds": 5}, @@ -346,7 +379,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "unlocking", "for": {"seconds": 5}, @@ -371,7 +404,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "jammed", "for": {"seconds": 5}, @@ -396,7 +429,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "locking", "for": {"seconds": 5}, diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py index 057061f5915..47f53a1ad20 100644 --- a/tests/components/loqed/test_init.py +++ b/tests/components/loqed/test_init.py @@ -4,6 +4,7 @@ import json from typing import Any from unittest.mock import AsyncMock, patch +import aiohttp from loqedAPI import loqed from homeassistant.components.loqed.const import DOMAIN @@ -58,6 +59,22 @@ async def test_setup_webhook_in_bridge( lock.registerWebhook.assert_called_with(f"{get_url(hass)}/api/webhook/Webhook_id") +async def test_cannot_connect_to_bridge_will_retry( + hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock +): + """Test webhook setup in loqed bridge.""" + config: dict[str, Any] = {DOMAIN: {}} + config_entry.add_to_hass(hass) + + with patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock", side_effect=aiohttp.ClientError + ): + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_setup_cloudhook_in_bridge( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock ): diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 8663ec0fc11..05bc7f372b8 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -48,8 +48,8 @@ async def test_lovelace_from_storage( assert response["result"] == {"yo": "hello"} - # Test with safe mode - hass.config.safe_mode = True + # Test with recovery mode + hass.config.recovery_mode = True await client.send_json({"id": 8, "type": "lovelace/config"}) response = await client.receive_json() assert not response["success"] diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index 1e2a121d6fb..f7830f03ed6 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -185,3 +185,26 @@ async def test_storage_resources_import_invalid( "resources" in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"] ) + + +async def test_storage_resources_safe_mode( + hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] +) -> None: + """Test defining resources in storage config.""" + + resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES] + hass_storage[resources.RESOURCE_STORAGE_KEY] = { + "key": resources.RESOURCE_STORAGE_KEY, + "version": 1, + "data": {"items": resource_config}, + } + assert await async_setup_component(hass, "lovelace", {}) + + client = await hass_ws_client(hass) + hass.config.safe_mode = True + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index f1a4b2da2ef..9ca8ac9e2ba 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -40,7 +40,7 @@ async def test_setup_demo_platform(hass: HomeAssistant) -> None: """Test setup.""" mock = MagicMock() add_entities = mock.MagicMock() - await demo.async_setup_platform(hass, {}, add_entities) + await demo.async_setup_entry(hass, {}, add_entities) assert add_entities.call_count == 1 diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py index d0970b96019..1198d7e6012 100644 --- a/tests/components/matrix/conftest.py +++ b/tests/components/matrix/conftest.py @@ -14,6 +14,8 @@ from nio import ( LoginError, LoginResponse, Response, + RoomResolveAliasError, + RoomResolveAliasResponse, UploadResponse, WhoamiError, WhoamiResponse, @@ -48,8 +50,15 @@ from tests.common import async_capture_events TEST_NOTIFIER_NAME = "matrix_notify" +TEST_HOMESERVER = "example.com" TEST_DEFAULT_ROOM = "!DefaultNotificationRoom:example.com" -TEST_JOINABLE_ROOMS = ["!RoomIdString:example.com", "#RoomAliasString:example.com"] +TEST_ROOM_A_ID = "!RoomA-ID:example.com" +TEST_ROOM_B_ID = "!RoomB-ID:example.com" +TEST_ROOM_B_ALIAS = "#RoomB-Alias:example.com" +TEST_JOINABLE_ROOMS = { + TEST_ROOM_A_ID: TEST_ROOM_A_ID, + TEST_ROOM_B_ALIAS: TEST_ROOM_B_ID, +} TEST_BAD_ROOM = "!UninvitedRoom:example.com" TEST_MXID = "@user:example.com" TEST_DEVICE_ID = "FAKEID" @@ -65,8 +74,16 @@ class _MockAsyncClient(AsyncClient): async def close(self): return None + async def room_resolve_alias(self, room_alias: str): + if room_id := TEST_JOINABLE_ROOMS.get(room_alias): + return RoomResolveAliasResponse( + room_alias=room_alias, room_id=room_id, servers=[TEST_HOMESERVER] + ) + else: + return RoomResolveAliasError(message=f"Could not resolve {room_alias}") + async def join(self, room_id: RoomID): - if room_id in TEST_JOINABLE_ROOMS: + if room_id in TEST_JOINABLE_ROOMS.values(): return JoinResponse(room_id=room_id) else: return JoinError(message="Not allowed to join this room.") @@ -102,10 +119,10 @@ class _MockAsyncClient(AsyncClient): async def room_send(self, *args, **kwargs): if not self.logged_in: raise LocalProtocolError - if kwargs["room_id"] in TEST_JOINABLE_ROOMS: - return Response() - else: + if kwargs["room_id"] not in TEST_JOINABLE_ROOMS.values(): return ErrorResponse(message="Cannot send a message in this room.") + else: + return Response() async def sync(self, *args, **kwargs): return None @@ -123,7 +140,7 @@ MOCK_CONFIG_DATA = { CONF_USERNAME: TEST_MXID, CONF_PASSWORD: TEST_PASSWORD, CONF_VERIFY_SSL: True, - CONF_ROOMS: TEST_JOINABLE_ROOMS, + CONF_ROOMS: list(TEST_JOINABLE_ROOMS), CONF_COMMANDS: [ { CONF_WORD: "WordTrigger", @@ -143,35 +160,35 @@ MOCK_CONFIG_DATA = { } MOCK_WORD_COMMANDS = { - "!RoomIdString:example.com": { + TEST_ROOM_A_ID: { "WordTrigger": { "word": "WordTrigger", "name": "WordTriggerEventName", - "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], } }, - "#RoomAliasString:example.com": { + TEST_ROOM_B_ID: { "WordTrigger": { "word": "WordTrigger", "name": "WordTriggerEventName", - "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], } }, } MOCK_EXPRESSION_COMMANDS = { - "!RoomIdString:example.com": [ + TEST_ROOM_A_ID: [ { "expression": re.compile("My name is (?P.*)"), "name": "ExpressionTriggerEventName", - "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], } ], - "#RoomAliasString:example.com": [ + TEST_ROOM_B_ID: [ { "expression": re.compile("My name is (?P.*)"), "name": "ExpressionTriggerEventName", - "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], } ], } diff --git a/tests/components/matrix/test_matrix_bot.py b/tests/components/matrix/test_matrix_bot.py index 0b150a629fe..0048f6665e8 100644 --- a/tests/components/matrix/test_matrix_bot.py +++ b/tests/components/matrix/test_matrix_bot.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from .conftest import ( MOCK_EXPRESSION_COMMANDS, MOCK_WORD_COMMANDS, - TEST_JOINABLE_ROOMS, TEST_NOTIFIER_NAME, + TEST_ROOM_A_ID, ) @@ -34,12 +34,13 @@ async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot): async def test_commands(hass, matrix_bot: MatrixBot, command_events): """Test that the configured commands were parsed correctly.""" + await hass.async_start() assert len(command_events) == 0 assert matrix_bot._word_commands == MOCK_WORD_COMMANDS assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS - room_id = TEST_JOINABLE_ROOMS[0] + room_id = TEST_ROOM_A_ID room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) # Test single-word command. diff --git a/tests/components/matrix/test_join_rooms.py b/tests/components/matrix/test_rooms.py similarity index 60% rename from tests/components/matrix/test_join_rooms.py rename to tests/components/matrix/test_rooms.py index 54856b91ac3..29081b80fd5 100644 --- a/tests/components/matrix/test_join_rooms.py +++ b/tests/components/matrix/test_rooms.py @@ -5,18 +5,24 @@ from homeassistant.components.matrix import MatrixBot from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS -async def test_join(matrix_bot: MatrixBot, caplog): +async def test_join(hass, matrix_bot: MatrixBot, caplog): """Test joining configured rooms.""" - # Join configured rooms. - await matrix_bot._join_rooms() + await hass.async_start() for room_id in TEST_JOINABLE_ROOMS: assert f"Joined or already in room '{room_id}'" in caplog.messages # Joining a disallowed room should not raise an exception. - matrix_bot._listening_rooms = [TEST_BAD_ROOM] + matrix_bot._listening_rooms = {TEST_BAD_ROOM: TEST_BAD_ROOM} await matrix_bot._join_rooms() assert ( f"Could not join room '{TEST_BAD_ROOM}': JoinError: Not allowed to join this room." in caplog.messages ) + + +async def test_resolve_aliases(hass, matrix_bot: MatrixBot): + """Test resolving configured room aliases into room ids.""" + + await hass.async_start() + assert matrix_bot._listening_rooms == TEST_JOINABLE_ROOMS diff --git a/tests/components/matrix/test_send_message.py b/tests/components/matrix/test_send_message.py index 34964f2b091..47c3e08aa48 100644 --- a/tests/components/matrix/test_send_message.py +++ b/tests/components/matrix/test_send_message.py @@ -17,30 +17,32 @@ async def test_send_message( hass: HomeAssistant, matrix_bot: MatrixBot, image_path, matrix_events, caplog ): """Test the send_message service.""" + + await hass.async_start() assert len(matrix_events) == 0 await matrix_bot._login() # Send a message without an attached image. - data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: TEST_JOINABLE_ROOMS} + data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: list(TEST_JOINABLE_ROOMS)} await hass.services.async_call( MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True ) - for room_id in TEST_JOINABLE_ROOMS: - assert f"Message delivered to room '{room_id}'" in caplog.messages + for room_alias_or_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_alias_or_id}'" in caplog.messages # Send an HTML message without an attached image. data = { ATTR_MESSAGE: "Test message", - ATTR_TARGET: TEST_JOINABLE_ROOMS, + ATTR_TARGET: list(TEST_JOINABLE_ROOMS), ATTR_DATA: {ATTR_FORMAT: FORMAT_HTML}, } await hass.services.async_call( MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True ) - for room_id in TEST_JOINABLE_ROOMS: - assert f"Message delivered to room '{room_id}'" in caplog.messages + for room_alias_or_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_alias_or_id}'" in caplog.messages # Send a message with an attached image. data[ATTR_DATA] = {ATTR_IMAGES: [image_path.name]} @@ -48,8 +50,8 @@ async def test_send_message( MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True ) - for room_id in TEST_JOINABLE_ROOMS: - assert f"Message delivered to room '{room_id}'" in caplog.messages + for room_alias_or_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_alias_or_id}'" in caplog.messages async def test_unsendable_message( diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index b89993dec65..ea1f65eab95 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -132,10 +132,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -150,7 +161,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -168,7 +179,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -186,7 +197,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_idle", } @@ -204,7 +215,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_paused", } @@ -222,7 +233,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_playing", } @@ -240,7 +251,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_buffering", } @@ -322,10 +333,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -340,7 +362,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 42608eacb09..afc46c87cff 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -205,10 +205,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -236,7 +247,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": trigger, }, @@ -306,10 +317,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -328,7 +350,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_on", }, @@ -354,10 +376,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -370,7 +403,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", "for": {"seconds": 5}, diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py index d9085f8251f..652763947df 100644 --- a/tests/components/met/test_init.py +++ b/tests/components/met/test_init.py @@ -9,6 +9,7 @@ from homeassistant.components.met.const import ( from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import init_integration @@ -48,3 +49,28 @@ async def test_fail_default_home_entry( "Skip setting up met.no integration; No Home location has been set" in caplog.text ) + + +async def test_removing_incorrect_devices( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_weather +) -> None: + """Test we remove incorrect devices.""" + entry = await init_integration(hass) + + device_reg = dr.async_get(hass) + device_reg.async_get_or_create( + config_entry_id=entry.entry_id, + name="Forecast_legacy", + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN,)}, + manufacturer="Met.no", + model="Forecast", + configuration_url="https://www.met.no/en", + ) + + assert await hass.config_entries.async_reload(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert not device_reg.async_get_device(identifiers={(DOMAIN,)}) + assert device_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + assert "Removing improper device Forecast_legacy" in caplog.text diff --git a/tests/components/metoffice/conftest.py b/tests/components/metoffice/conftest.py index b1d1c9f508e..1633fae5ee8 100644 --- a/tests/components/metoffice/conftest.py +++ b/tests/components/metoffice/conftest.py @@ -1,9 +1,14 @@ """Fixtures for Met Office weather integration tests.""" +import sys from unittest.mock import patch -from datapoint.exceptions import APIException import pytest +if sys.version_info < (3, 12): + from datapoint.exceptions import APIException +else: + collect_ignore_glob = ["test_*.py"] + @pytest.fixture def mock_simple_manager_fail(): diff --git a/tests/components/metoffice/snapshots/test_weather.ambr b/tests/components/metoffice/snapshots/test_weather.ambr new file mode 100644 index 00000000000..38df9f04ab2 --- /dev/null +++ b/tests/components/metoffice/snapshots/test_weather.ambr @@ -0,0 +1,1929 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service.2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service.3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_3_hourly] + list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_3_hourly].1 + list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_3_hourly].2 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_3_hourly].3 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_daily] + list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_daily].1 + list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_daily].2 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_daily].3 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]) +# --- diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 673475c0303..6c6041b1869 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -2,13 +2,22 @@ import datetime from datetime import timedelta import json +from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import requests_mock +from requests_mock.adapter import _Matcher +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.metoffice.const import DOMAIN +from homeassistant.components.metoffice.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.components.weather import ( + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, +) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import async_get as get_dev_reg from homeassistant.util import utcnow @@ -21,6 +30,43 @@ from .const import ( ) from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.typing import WebSocketGenerator + + +@pytest.fixture +def no_sensor(): + """Remove sensors.""" + with patch( + "homeassistant.components.metoffice.sensor.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def wavertree_data(requests_mock: requests_mock.Mocker) -> dict[str, _Matcher]: + """Mock data for the Wavertree location.""" + # all metoffice test data encapsulated in here + mock_json = json.loads(load_fixture("metoffice.json")) + all_sites = json.dumps(mock_json["all_sites"]) + wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) + wavertree_daily = json.dumps(mock_json["wavertree_daily"]) + + sitelist_mock = requests_mock.get( + "/public/data/val/wxfcs/all/json/sitelist/", text=all_sites + ) + wavertree_hourly_mock = requests_mock.get( + "/public/data/val/wxfcs/all/json/354107?res=3hourly", + text=wavertree_hourly, + ) + wavertree_daily_mock = requests_mock.get( + "/public/data/val/wxfcs/all/json/354107?res=daily", + text=wavertree_daily, + ) + return { + "sitelist_mock": sitelist_mock, + "wavertree_hourly_mock": wavertree_hourly_mock, + "wavertree_daily_mock": wavertree_daily_mock, + } @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) @@ -54,22 +100,17 @@ async def test_site_cannot_connect( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_site_cannot_update( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data ) -> None: """Test we handle cannot connect error.""" + registry = er.async_get(hass) - # all metoffice test data encapsulated in here - mock_json = json.loads(load_fixture("metoffice.json")) - all_sites = json.dumps(mock_json["all_sites"]) - wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) - wavertree_daily = json.dumps(mock_json["wavertree_daily"]) - - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", text=wavertree_daily + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", ) entry = MockConfigEntry( @@ -102,24 +143,17 @@ async def test_site_cannot_update( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_one_weather_site_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data ) -> None: """Test the Met Office weather platform.""" + registry = er.async_get(hass) - # all metoffice test data encapsulated in here - mock_json = json.loads(load_fixture("metoffice.json")) - all_sites = json.dumps(mock_json["all_sites"]) - wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) - wavertree_daily = json.dumps(mock_json["wavertree_daily"]) - - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", - text=wavertree_hourly, - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", - text=wavertree_daily, + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", ) entry = MockConfigEntry( @@ -185,25 +219,30 @@ async def test_one_weather_site_running( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_two_weather_sites_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data ) -> None: """Test we handle two different weather sites both running.""" + registry = er.async_get(hass) + + # Pre-create the hourly entities + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", + ) + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "52.75556_0.44231", + suggested_object_id="met_office_king_s_lynn_3_hourly", + ) # all metoffice test data encapsulated in here mock_json = json.loads(load_fixture("metoffice.json")) - all_sites = json.dumps(mock_json["all_sites"]) - wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) - wavertree_daily = json.dumps(mock_json["wavertree_daily"]) kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) kingslynn_daily = json.dumps(mock_json["kingslynn_daily"]) - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", text=wavertree_daily - ) requests_mock.get( "/public/data/val/wxfcs/all/json/322380?res=3hourly", text=kingslynn_hourly ) @@ -327,3 +366,214 @@ async def test_two_weather_sites_running( assert weather.attributes.get("forecast")[2]["temperature"] == 11 assert weather.attributes.get("forecast")[2]["wind_speed"] == 11.27 assert weather.attributes.get("forecast")[2]["wind_bearing"] == "ESE" + + +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +async def test_new_config_entry(hass: HomeAssistant, no_sensor, wavertree_data) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 1 + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + + +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +async def test_legacy_config_entry( + hass: HomeAssistant, no_sensor, wavertree_data +) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids("weather")) == 2 + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + + +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +async def test_forecast_service( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + requests_mock: requests_mock.Mocker, + snapshot: SnapshotAssertion, + no_sensor, + wavertree_data: dict[str, _Matcher], +) -> None: + """Test multiple forecast.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert wavertree_data["wavertree_daily_mock"].call_count == 1 + assert wavertree_data["wavertree_hourly_mock"].call_count == 1 + + for forecast_type in ("daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.met_office_wavertree_daily", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # Calling the services should use cached data + assert wavertree_data["wavertree_daily_mock"].call_count == 1 + assert wavertree_data["wavertree_hourly_mock"].call_count == 1 + + # Trigger data refetch + freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert wavertree_data["wavertree_daily_mock"].call_count == 2 + assert wavertree_data["wavertree_hourly_mock"].call_count == 1 + + for forecast_type in ("daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.met_office_wavertree_daily", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # Calling the services should update the hourly forecast + assert wavertree_data["wavertree_daily_mock"].call_count == 2 + assert wavertree_data["wavertree_hourly_mock"].call_count == 2 + + # Update fails + requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="") + + freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.met_office_wavertree_daily", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] == [] + + +@pytest.mark.parametrize( + "entity_id", + [ + "weather.met_office_wavertree_3_hourly", + "weather.met_office_wavertree_daily", + ], +) +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + no_sensor, + wavertree_data: dict[str, _Matcher], + entity_id: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + registry = er.async_get(hass) + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + for forecast_type in ("daily", "hourly"): + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot + + await client.send_json_auto_id( + { + "type": "unsubscribe_events", + "subscription": subscription_id, + } + ) + msg = await client.receive_json() + assert msg["success"] diff --git a/tests/components/minecraft_server/conftest.py b/tests/components/minecraft_server/conftest.py new file mode 100644 index 00000000000..b118b15d08a --- /dev/null +++ b/tests/components/minecraft_server/conftest.py @@ -0,0 +1,42 @@ +"""Fixtures for Minecraft Server integration tests.""" +import pytest + +from homeassistant.components.minecraft_server.api import MinecraftServerType +from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE + +from .const import TEST_ADDRESS, TEST_CONFIG_ENTRY_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture +def java_mock_config_entry() -> MockConfigEntry: + """Create YouTube entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=None, + entry_id=TEST_CONFIG_ENTRY_ID, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_ADDRESS: TEST_ADDRESS, + CONF_TYPE: MinecraftServerType.JAVA_EDITION, + }, + version=3, + ) + + +@pytest.fixture +def bedrock_mock_config_entry() -> MockConfigEntry: + """Create YouTube entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=None, + entry_id=TEST_CONFIG_ENTRY_ID, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_ADDRESS: TEST_ADDRESS, + CONF_TYPE: MinecraftServerType.BEDROCK_EDITION, + }, + version=3, + ) diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index c7eb0e4b096..56be9132f19 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -1,28 +1,39 @@ """Constants for Minecraft Server integration tests.""" from mcstatus.motd import Motd from mcstatus.status_response import ( + BedrockStatusPlayers, + BedrockStatusResponse, + BedrockStatusVersion, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion, + RawJavaResponse, + RawJavaResponsePlayer, + RawJavaResponsePlayers, + RawJavaResponseVersion, ) +from homeassistant.components.minecraft_server.api import MinecraftServerData + +TEST_CONFIG_ENTRY_ID: str = "01234567890123456789012345678901" TEST_HOST = "mc.dummyserver.com" TEST_PORT = 25566 TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" -TEST_JAVA_STATUS_RESPONSE_RAW = { - "description": {"text": "Dummy Description"}, - "version": {"name": "Dummy Version", "protocol": 123}, - "players": { - "online": 3, - "max": 10, - "sample": [ - {"name": "Player 1", "id": "1"}, - {"name": "Player 2", "id": "2"}, - {"name": "Player 3", "id": "3"}, +TEST_JAVA_STATUS_RESPONSE_RAW = RawJavaResponse( + description="Dummy MOTD", + players=RawJavaResponsePlayers( + online=3, + max=10, + sample=[ + RawJavaResponsePlayer(id="1", name="Player 1"), + RawJavaResponsePlayer(id="2", name="Player 2"), + RawJavaResponsePlayer(id="3", name="Player 3"), ], - }, -} + ), + version=RawJavaResponseVersion(name="Dummy Version", protocol=123), + favicon="Dummy Icon", +) TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( raw=TEST_JAVA_STATUS_RESPONSE_RAW, @@ -32,3 +43,38 @@ TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( icon=None, latency=5, ) + +TEST_JAVA_DATA = MinecraftServerData( + latency=5, + motd="Dummy MOTD", + players_max=10, + players_online=3, + protocol_version=123, + version="Dummy Version", + players_list=["Player 1", "Player 2", "Player 3"], + edition=None, + game_mode=None, + map_name=None, +) + +TEST_BEDROCK_STATUS_RESPONSE = BedrockStatusResponse( + players=BedrockStatusPlayers(online=3, max=10), + version=BedrockStatusVersion(brand="MCPE", name="Dummy Version", protocol=123), + motd=Motd.parse("Dummy MOTD", bedrock=True), + latency=5, + gamemode="Dummy Game Mode", + map_name="Dummy Map Name", +) + +TEST_BEDROCK_DATA = MinecraftServerData( + latency=5, + motd="Dummy MOTD", + players_max=10, + players_online=3, + protocol_version=123, + version="Dummy Version", + players_list=None, + edition="Dummy Edition", + game_mode="Dummy Game Mode", + map_name="Dummy Map Name", +) diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..ef03e36343b --- /dev/null +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-status_response1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Minecraft Server Status', + 'icon': 'mdi:lan', + }), + 'context': , + 'entity_id': 'binary_sensor.minecraft_server_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[java_mock_config_entry-JavaServer-status_response0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Minecraft Server Status', + 'icon': 'mdi:lan', + }), + 'context': , + 'entity_id': 'binary_sensor.minecraft_server_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Minecraft Server Status', + 'icon': 'mdi:lan', + }), + 'context': , + 'entity_id': 'binary_sensor.minecraft_server_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-status_response0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Minecraft Server Status', + 'icon': 'mdi:lan', + }), + 'context': , + 'entity_id': 'binary_sensor.minecraft_server_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- \ No newline at end of file diff --git a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..72d79795c6a --- /dev/null +++ b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics[bedrock_mock_config_entry-BedrockServer-status_response1] + dict({ + 'config_entry': dict({ + 'entry_id': '01234567890123456789012345678901', + 'unique_id': None, + 'version': 3, + }), + 'config_entry_data': dict({ + 'address': '**REDACTED**', + 'name': '**REDACTED**', + 'type': 'Bedrock Edition', + }), + 'config_entry_options': dict({ + }), + 'server_data': dict({ + 'edition': 'MCPE', + 'game_mode': 'Dummy Game Mode', + 'latency': 5, + 'map_name': 'Dummy Map Name', + 'motd': 'Dummy MOTD', + 'players_list': None, + 'players_max': 10, + 'players_online': 3, + 'protocol_version': 123, + 'version': 'Dummy Version', + }), + }) +# --- +# name: test_config_entry_diagnostics[java_mock_config_entry-JavaServer-status_response0] + dict({ + 'config_entry': dict({ + 'entry_id': '01234567890123456789012345678901', + 'unique_id': None, + 'version': 3, + }), + 'config_entry_data': dict({ + 'address': '**REDACTED**', + 'name': '**REDACTED**', + 'type': 'Java Edition', + }), + 'config_entry_options': dict({ + }), + 'server_data': dict({ + 'edition': None, + 'game_mode': None, + 'latency': 5, + 'map_name': None, + 'motd': 'Dummy MOTD', + 'players_list': '**REDACTED**', + 'players_max': 10, + 'players_online': 3, + 'protocol_version': 123, + 'version': 'Dummy Version', + }), + }) +# --- diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fed0ae93c66 --- /dev/null +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -0,0 +1,413 @@ +# serializer version: 1 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Latency', + 'icon': 'mdi:signal', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_latency', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players online', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_online', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players max', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_max', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server World message', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_world_message', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy MOTD', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_version', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Version', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Protocol version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_protocol_version', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Map name', + 'icon': 'mdi:map', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_map_name', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Map Name', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Game mode', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_game_mode', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Game Mode', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Edition', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_edition', + 'last_changed': , + 'last_updated': , + 'state': 'MCPE', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Latency', + 'icon': 'mdi:signal', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_latency', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players online', + 'icon': 'mdi:account-multiple', + 'players_list': list([ + 'Player 1', + 'Player 2', + 'Player 3', + ]), + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_online', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players max', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_max', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server World message', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_world_message', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy MOTD', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_version', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Version', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Protocol version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_protocol_version', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Latency', + 'icon': 'mdi:signal', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_latency', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players online', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_online', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players max', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_max', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server World message', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_world_message', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy MOTD', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_version', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Version', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Protocol version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_protocol_version', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Map name', + 'icon': 'mdi:map', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_map_name', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Map Name', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Game mode', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_game_mode', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Game Mode', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Edition', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_edition', + 'last_changed': , + 'last_updated': , + 'state': 'MCPE', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Latency', + 'icon': 'mdi:signal', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_latency', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players online', + 'icon': 'mdi:account-multiple', + 'players_list': list([ + 'Player 1', + 'Player 2', + 'Player 3', + ]), + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_online', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players max', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_max', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server World message', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_world_message', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy MOTD', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_version', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Version', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Protocol version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_protocol_version', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py new file mode 100644 index 00000000000..9fae35b113d --- /dev/null +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -0,0 +1,128 @@ +"""Tests for Minecraft Server binary sensor.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from mcstatus import BedrockServer, JavaServer +from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant + +from .const import ( + TEST_BEDROCK_STATUS_RESPONSE, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response"), + [ + ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), + ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ], +) +async def test_binary_sensor( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response"), + [ + ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), + ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ], +) +async def test_binary_sensor_update( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor update.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response"), + [ + ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), + ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ], +) +async def test_binary_sensor_update_failure( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + request: pytest.FixtureRequest, + freezer: FrozenDateTimeFactory, +) -> None: + """Test failed binary sensor update.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + side_effect=OSError, + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.minecraft_server_status").state == STATE_OFF + ) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 88afa6576d5..785905492c1 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -2,15 +2,22 @@ from unittest.mock import patch -from mcstatus import JavaServer +from mcstatus import BedrockServer, JavaServer +from homeassistant.components.minecraft_server.api import MinecraftServerType from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT +from .const import ( + TEST_ADDRESS, + TEST_BEDROCK_STATUS_RESPONSE, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) USER_INPUT = { CONF_NAME: DEFAULT_NAME, @@ -28,10 +35,13 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_lookup_failed(hass: HomeAssistant) -> None: +async def test_address_validation_failure(hass: HomeAssistant) -> None: """Test error in case of a failed connection.""" with patch( - "mcstatus.server.JavaServer.async_lookup", + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( @@ -42,12 +52,18 @@ async def test_lookup_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_connection_failed(hass: HomeAssistant) -> None: - """Test error in case of a failed connection.""" +async def test_java_connection_failure(hass: HomeAssistant) -> None: + """Test error in case of a failed connection to a Java Edition server.""" with patch( - "mcstatus.server.JavaServer.async_lookup", + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), - ), patch("mcstatus.server.JavaServer.async_status", side_effect=OSError): + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + side_effect=OSError, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) @@ -56,13 +72,33 @@ async def test_connection_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_connection_succeeded(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with a host name.""" +async def test_bedrock_connection_failure(hass: HomeAssistant) -> None: + """Test error in case of a failed connection to a Bedrock Edition server.""" with patch( - "mcstatus.server.JavaServer.async_lookup", + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_java_connection(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Java Edition server.""" + with patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( - "mcstatus.server.JavaServer.async_status", + "homeassistant.components.minecraft_server.api.JavaServer.async_status", return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( @@ -73,3 +109,56 @@ async def test_connection_succeeded(hass: HomeAssistant) -> None: assert result["title"] == USER_INPUT[CONF_ADDRESS] assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION + + +async def test_bedrock_connection(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Bedrock Edition server.""" + with patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + return_value=TEST_BEDROCK_STATUS_RESPONSE, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_ADDRESS] + assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] + assert result["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION + + +async def test_recovery(hass: HomeAssistant) -> None: + """Test config flow recovery (successful connection after a failed connection).""" + with patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + side_effect=ValueError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + return_value=TEST_BEDROCK_STATUS_RESPONSE, + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=USER_INPUT + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == USER_INPUT[CONF_ADDRESS] + assert result2["data"][CONF_NAME] == USER_INPUT[CONF_NAME] + assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result2["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION diff --git a/tests/components/minecraft_server/test_diagnostics.py b/tests/components/minecraft_server/test_diagnostics.py new file mode 100644 index 00000000000..6979325fa0c --- /dev/null +++ b/tests/components/minecraft_server/test_diagnostics.py @@ -0,0 +1,60 @@ +"""Tests for Minecraft Server diagnostics.""" +from unittest.mock import patch + +from mcstatus import BedrockServer, JavaServer +from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from .const import ( + TEST_BEDROCK_STATUS_RESPONSE, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response"), + [ + ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), + ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ], +) +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test fetching of the config entry diagnostics.""" + + # Use 'request' fixture to access 'mock_config_entry' fixture, as it cannot be used directly in 'parametrize'. + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + # Setup mock entry. + with patch( + f"mcstatus.server.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"mcstatus.server.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Test diagnostics. + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 1e3679fb1e3..09e411f0b62 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -2,15 +2,23 @@ from unittest.mock import patch from mcstatus import JavaServer +import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT +from .const import ( + TEST_ADDRESS, + TEST_CONFIG_ENTRY_ID, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) from tests.common import MockConfigEntry @@ -28,11 +36,13 @@ SENSOR_KEYS = [ BINARY_SENSOR_KEYS = {"v1": "Status", "v2": "status"} -def create_v1_mock_config_entry(hass: HomeAssistant) -> int: - """Create mock config entry.""" - config_entry_v1 = MockConfigEntry( +@pytest.fixture +def v1_mock_config_entry() -> MockConfigEntry: + """Create mock config entry with version 1.""" + return MockConfigEntry( domain=DOMAIN, unique_id=TEST_UNIQUE_ID, + entry_id=TEST_CONFIG_ENTRY_ID, data={ CONF_NAME: DEFAULT_NAME, CONF_HOST: TEST_HOST, @@ -40,14 +50,10 @@ def create_v1_mock_config_entry(hass: HomeAssistant) -> int: }, version=1, ) - config_entry_id = config_entry_v1.entry_id - config_entry_v1.add_to_hass(hass) - - return config_entry_id -def create_v1_mock_device_entry(hass: HomeAssistant, config_entry_id: int) -> int: - """Create mock device entry.""" +def create_v1_mock_device_entry(hass: HomeAssistant, config_entry_id: str) -> str: + """Create mock device entry with version 1.""" device_registry = dr.async_get(hass) device_entry_v1 = device_registry.async_get_or_create( config_entry_id=config_entry_id, @@ -62,9 +68,9 @@ def create_v1_mock_device_entry(hass: HomeAssistant, config_entry_id: int) -> in def create_v1_mock_sensor_entity_entries( - hass: HomeAssistant, config_entry_id: int, device_entry_id: int + hass: HomeAssistant, config_entry_id: str, device_entry_id: str ) -> list[dict]: - """Create mock sensor entity entries.""" + """Create mock sensor entity entries with version 1.""" sensor_entity_id_key_mapping_list = [] config_entry = hass.config_entries.async_get_entry(config_entry_id) entity_registry = er.async_get(hass) @@ -87,9 +93,9 @@ def create_v1_mock_sensor_entity_entries( def create_v1_mock_binary_sensor_entity_entry( - hass: HomeAssistant, config_entry_id: int, device_entry_id: int + hass: HomeAssistant, config_entry_id: str, device_entry_id: str ) -> dict: - """Create mock binary sensor entity entry.""" + """Create mock binary sensor entity entry with version 1.""" config_entry = hass.config_entries.async_get_entry(config_entry_id) entity_registry = er.async_get(hass) entity_unique_id = f"{TEST_UNIQUE_ID}-{BINARY_SENSOR_KEYS['v1']}" @@ -109,51 +115,121 @@ def create_v1_mock_binary_sensor_entity_entry( return binary_sensor_entity_id_key_mapping -async def test_entry_migration(hass: HomeAssistant) -> None: +async def test_setup_and_unload_entry( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test successful entry setup and unload.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ): + assert await hass.config_entries.async_setup(java_mock_config_entry.entry_id) + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(java_mock_config_entry.entry_id) + await hass.async_block_till_done() + assert not hass.data.get(DOMAIN) + assert java_mock_config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_failure( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test failed entry setup.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + side_effect=ValueError, + ): + assert not await hass.config_entries.async_setup( + java_mock_config_entry.entry_id + ) + + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_not_ready( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test entry setup not ready.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=OSError, + ): + assert not await hass.config_entries.async_setup( + java_mock_config_entry.entry_id + ) + + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_entry_migration( + hass: HomeAssistant, v1_mock_config_entry: MockConfigEntry +) -> None: """Test entry migration from version 1 to 3, where host and port is required for the connection to the server.""" - config_entry_id = create_v1_mock_config_entry(hass) - device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + v1_mock_config_entry.add_to_hass(hass) + + device_entry_id = create_v1_mock_device_entry(hass, v1_mock_config_entry.entry_id) sensor_entity_id_key_mapping_list = create_v1_mock_sensor_entity_entries( - hass, config_entry_id, device_entry_id + hass, v1_mock_config_entry.entry_id, device_entry_id ) binary_sensor_entity_id_key_mapping = create_v1_mock_binary_sensor_entity_entry( - hass, config_entry_id, device_entry_id + hass, v1_mock_config_entry.entry_id, device_entry_id ) # Trigger migration. with patch( - "mcstatus.server.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.lookup", side_effect=[ - ValueError, - JavaServer(host=TEST_HOST, port=TEST_PORT), - JavaServer(host=TEST_HOST, port=TEST_PORT), + ValueError, # async_migrate_entry + JavaServer(host=TEST_HOST, port=TEST_PORT), # async_migrate_entry + JavaServer(host=TEST_HOST, port=TEST_PORT), # async_setup_entry ], ), patch( - "mcstatus.server.JavaServer.async_status", + "homeassistant.components.minecraft_server.api.JavaServer.async_status", return_value=TEST_JAVA_STATUS_RESPONSE, ): - assert await hass.config_entries.async_setup(config_entry_id) + assert await hass.config_entries.async_setup(v1_mock_config_entry.entry_id) await hass.async_block_till_done() + migrated_config_entry = v1_mock_config_entry + # Test migrated config entry. - config_entry = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry.unique_id is None - assert config_entry.data == { + assert migrated_config_entry.unique_id is None + assert migrated_config_entry.data == { CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, } - assert config_entry.version == 3 + assert migrated_config_entry.version == 3 + assert migrated_config_entry.state == ConfigEntryState.LOADED # Test migrated device entry. device_registry = dr.async_get(hass) device_entry = device_registry.async_get(device_entry_id) - assert device_entry.identifiers == {(DOMAIN, config_entry_id)} + assert device_entry.identifiers == {(DOMAIN, migrated_config_entry.entry_id)} # Test migrated sensor entity entries. entity_registry = er.async_get(hass) for mapping in sensor_entity_id_key_mapping_list: entity_entry = entity_registry.async_get(mapping["entity_id"]) - assert entity_entry.unique_id == f"{config_entry_id}-{mapping['key']}" + assert ( + entity_entry.unique_id + == f"{migrated_config_entry.entry_id}-{mapping['key']}" + ) # Test migrated binary sensor entity entry. entity_entry = entity_registry.async_get( @@ -161,59 +237,70 @@ async def test_entry_migration(hass: HomeAssistant) -> None: ) assert ( entity_entry.unique_id - == f"{config_entry_id}-{binary_sensor_entity_id_key_mapping['key']}" + == f"{migrated_config_entry.entry_id}-{binary_sensor_entity_id_key_mapping['key']}" ) -async def test_entry_migration_host_only(hass: HomeAssistant) -> None: +async def test_entry_migration_host_only( + hass: HomeAssistant, v1_mock_config_entry: MockConfigEntry +) -> None: """Test entry migration from version 1 to 3, where host alone is sufficient for the connection to the server.""" - config_entry_id = create_v1_mock_config_entry(hass) - device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) - create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) - create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + v1_mock_config_entry.add_to_hass(hass) + + device_entry_id = create_v1_mock_device_entry(hass, v1_mock_config_entry.entry_id) + create_v1_mock_sensor_entity_entries( + hass, v1_mock_config_entry.entry_id, device_entry_id + ) + create_v1_mock_binary_sensor_entity_entry( + hass, v1_mock_config_entry.entry_id, device_entry_id + ) # Trigger migration. with patch( - "mcstatus.server.JavaServer.lookup", - side_effect=[ - JavaServer(host=TEST_HOST, port=TEST_PORT), - JavaServer(host=TEST_HOST, port=TEST_PORT), - ], + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( - "mcstatus.server.JavaServer.async_status", + "homeassistant.components.minecraft_server.api.JavaServer.async_status", return_value=TEST_JAVA_STATUS_RESPONSE, ): - assert await hass.config_entries.async_setup(config_entry_id) + assert await hass.config_entries.async_setup(v1_mock_config_entry.entry_id) await hass.async_block_till_done() # Test migrated config entry. - config_entry = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry.unique_id is None - assert config_entry.data == { + assert v1_mock_config_entry.unique_id is None + assert v1_mock_config_entry.data == { CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_HOST, } - assert config_entry.version == 3 + assert v1_mock_config_entry.version == 3 + assert v1_mock_config_entry.state == ConfigEntryState.LOADED -async def test_entry_migration_v3_failure(hass: HomeAssistant) -> None: +async def test_entry_migration_v3_failure( + hass: HomeAssistant, v1_mock_config_entry: MockConfigEntry +) -> None: """Test failed entry migration from version 2 to 3.""" - config_entry_id = create_v1_mock_config_entry(hass) - device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) - create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) - create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + v1_mock_config_entry.add_to_hass(hass) + + device_entry_id = create_v1_mock_device_entry(hass, v1_mock_config_entry.entry_id) + create_v1_mock_sensor_entity_entries( + hass, v1_mock_config_entry.entry_id, device_entry_id + ) + create_v1_mock_binary_sensor_entity_entry( + hass, v1_mock_config_entry.entry_id, device_entry_id + ) # Trigger migration. with patch( - "mcstatus.server.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.lookup", side_effect=[ - ValueError, - ValueError, + ValueError, # async_migrate_entry + ValueError, # async_migrate_entry ], ): - assert not await hass.config_entries.async_setup(config_entry_id) + assert not await hass.config_entries.async_setup(v1_mock_config_entry.entry_id) await hass.async_block_till_done() # Test config entry. - config_entry = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry.version == 2 + assert v1_mock_config_entry.version == 2 + assert v1_mock_config_entry.state == ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py new file mode 100644 index 00000000000..006c735e034 --- /dev/null +++ b/tests/components/minecraft_server/test_sensor.py @@ -0,0 +1,239 @@ +"""Tests for Minecraft Server sensors.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from mcstatus import BedrockServer, JavaServer +from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .const import ( + TEST_BEDROCK_STATUS_RESPONSE, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) + +from tests.common import async_fire_time_changed + +JAVA_SENSOR_ENTITIES: list[str] = [ + "sensor.minecraft_server_latency", + "sensor.minecraft_server_players_online", + "sensor.minecraft_server_players_max", + "sensor.minecraft_server_world_message", + "sensor.minecraft_server_version", + "sensor.minecraft_server_protocol_version", +] + +JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ + "sensor.minecraft_server_players_max", + "sensor.minecraft_server_protocol_version", +] + +BEDROCK_SENSOR_ENTITIES: list[str] = [ + "sensor.minecraft_server_latency", + "sensor.minecraft_server_players_online", + "sensor.minecraft_server_players_max", + "sensor.minecraft_server_world_message", + "sensor.minecraft_server_version", + "sensor.minecraft_server_protocol_version", + "sensor.minecraft_server_map_name", + "sensor.minecraft_server_game_mode", + "sensor.minecraft_server_edition", +] + +BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ + "sensor.minecraft_server_players_max", + "sensor.minecraft_server_protocol_version", + "sensor.minecraft_server_edition", +] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response", "entity_ids"), + [ + ( + "java_mock_config_entry", + JavaServer, + TEST_JAVA_STATUS_RESPONSE, + JAVA_SENSOR_ENTITIES, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + TEST_BEDROCK_STATUS_RESPONSE, + BEDROCK_SENSOR_ENTITIES, + ), + ], +) +async def test_sensor( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + entity_ids: list[str], + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + for entity_id in entity_ids: + assert hass.states.get(entity_id) == snapshot + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response", "entity_ids"), + [ + ( + "java_mock_config_entry", + JavaServer, + TEST_JAVA_STATUS_RESPONSE, + JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + TEST_BEDROCK_STATUS_RESPONSE, + BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, + ), + ], +) +async def test_sensor_disabled_by_default( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + entity_ids: list[str], + request: pytest.FixtureRequest, +) -> None: + """Test sensor, which is disabled by default.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response", "entity_ids"), + [ + ( + "java_mock_config_entry", + JavaServer, + TEST_JAVA_STATUS_RESPONSE, + JAVA_SENSOR_ENTITIES, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + TEST_BEDROCK_STATUS_RESPONSE, + BEDROCK_SENSOR_ENTITIES, + ), + ], +) +async def test_sensor_update( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + entity_ids: list[str], + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor update.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in entity_ids: + assert hass.states.get(entity_id) == snapshot + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response", "entity_ids"), + [ + ( + "java_mock_config_entry", + JavaServer, + TEST_JAVA_STATUS_RESPONSE, + JAVA_SENSOR_ENTITIES, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + TEST_BEDROCK_STATUS_RESPONSE, + BEDROCK_SENSOR_ENTITIES, + ), + ], +) +async def test_sensor_update_failure( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + entity_ids: list[str], + request: pytest.FixtureRequest, + freezer: FrozenDateTimeFactory, +) -> None: + """Test failed sensor update.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + side_effect=OSError, + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 7532319854a..0d5c9ee2e8d 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -22,6 +22,7 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, + SERVICE_RELOAD, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, @@ -36,6 +37,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .test_common import ( help_custom_config, @@ -184,11 +186,9 @@ async def test_fail_setup_without_state_or_command_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, valid ) -> None: """Test for failing setup with no state or command topic.""" - if valid: - await mqtt_mock_entry() - return - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() + state = hass.states.get(f"{alarm_control_panel.DOMAIN}.test") + assert (state is not None) == valid @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -306,15 +306,13 @@ async def test_supported_features( valid: bool, ) -> None: """Test conditional enablement of supported features.""" + assert await mqtt_mock_entry() + state = hass.states.get("alarm_control_panel.test") if valid: - await mqtt_mock_entry() - assert ( - hass.states.get("alarm_control_panel.test").attributes["supported_features"] - == expected_features - ) + assert state is not None + assert state.attributes["supported_features"] == expected_features else: - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert state is None @pytest.mark.parametrize( @@ -1269,3 +1267,90 @@ async def test_skipped_async_ha_write_state( """Test a write state command is only called when there is change.""" await mqtt_mock_entry() await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "alarm_control_panel": { + "name": "test", + "invalid_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_after_invalid_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reloading yaml config fails.""" + with patch( + "homeassistant.components.mqtt.async_delete_issue" + ) as mock_async_remove_issue: + assert await mqtt_mock_entry() + assert hass.states.get("alarm_control_panel.test") is None + assert ( + "extra keys not allowed @ data['invalid_topic'] for " + "manual configured MQTT alarm_control_panel item, " + "in ?, line ? Got {'name': 'test', 'invalid_topic': 'test-topic'}" + in caplog.text + ) + + # Reload with an valid config + valid_config = { + "mqtt": [ + { + "alarm_control_panel": { + "name": "test", + "command_topic": "test-topic", + "state_topic": "alarm/state", + } + }, + ] + } + with patch( + "homeassistant.config.load_yaml_config_file", return_value=valid_config + ): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Test the config is loaded now and that the existing issue is removed + assert hass.states.get("alarm_control_panel.test") is not None + assert mock_async_remove_issue.call_count == 1 + + # Reload with an invalid config + invalid_config = { + "mqtt": [ + { + "alarm_control_panel": { + "name": "test", + "command_topic": "test-topic", + "invalid_option": "should_fail", + } + }, + ] + } + with patch( + "homeassistant.config.load_yaml_config_file", return_value=invalid_config + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Make sure the config is loaded now + assert hass.states.get("alarm_control_panel.test") is not None diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index e7a4c9ab1aa..3cc04b79e3a 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -580,9 +580,8 @@ async def test_invalid_device_class( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the setting of an invalid sensor class.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: expected BinarySensorDeviceClass" in caplog.text + assert await mqtt_mock_entry() + assert "expected BinarySensorDeviceClass" in caplog.text @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 481e98f0099..f2f91c5ca75 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -443,7 +443,7 @@ async def test_entity_debug_info_message( mqtt.DOMAIN: { button.DOMAIN: { "name": "test", - "state_topic": "test-topic", + "command_topic": "test-topic", "device_class": "foobarnotreal", } } @@ -451,11 +451,13 @@ async def test_entity_debug_info_message( ], ) async def test_invalid_device_class( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test device_class option with invalid value.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() + assert "expected ButtonDeviceClass" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9c0adbe2adf..89eaf87fb3a 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -138,9 +138,8 @@ async def test_preset_none_in_preset_modes( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the preset mode payload reset configuration.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: not a valid value" in caplog.text + assert await mqtt_mock_entry() + assert "not a valid value" in caplog.text @pytest.mark.parametrize( @@ -2448,11 +2447,11 @@ async def test_publishing_with_custom_encoding( @pytest.mark.parametrize( ("hass_config", "valid"), [ - ( + ( # test_valid_humidity_min_max { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_valid_humidity_min_max", + "name": "test", "min_humidity": 20, "max_humidity": 80, }, @@ -2460,11 +2459,11 @@ async def test_publishing_with_custom_encoding( }, True, ), - ( + ( # test_invalid_humidity_min_max_1 { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_invalid_humidity_min_max_1", + "name": "test", "min_humidity": 0, "max_humidity": 101, }, @@ -2472,11 +2471,11 @@ async def test_publishing_with_custom_encoding( }, False, ), - ( + ( # test_invalid_humidity_min_max_2 { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_invalid_humidity_min_max_2", + "name": "test", "max_humidity": 20, "min_humidity": 40, }, @@ -2484,11 +2483,11 @@ async def test_publishing_with_custom_encoding( }, False, ), - ( + ( # test_valid_humidity_state { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_valid_humidity_state", + "name": "test", "target_humidity_state_topic": "humidity-state", "target_humidity_command_topic": "humidity-command", }, @@ -2496,11 +2495,11 @@ async def test_publishing_with_custom_encoding( }, True, ), - ( + ( # test_invalid_humidity_state { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_invalid_humidity_state", + "name": "test", "target_humidity_state_topic": "humidity-state", }, } @@ -2515,11 +2514,9 @@ async def test_humidity_configuration_validity( valid: bool, ) -> None: """Test the validity of humidity configurations.""" - if valid: - await mqtt_mock_entry() - return - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() + state = hass.states.get("climate.test") + assert (state is not None) == valid async def test_reloadable( diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 64bece5369e..0664f6e8d6f 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import ANY, MagicMock, patch import pytest +import voluptuous as vol import yaml from homeassistant import config as module_hass_config @@ -23,6 +24,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_RELOAD, STATE_UNAVAILABLE, + EntityCategory, ) from homeassistant.core import HomeAssistant from homeassistant.generated.mqtt import MQTT @@ -362,6 +364,7 @@ async def help_test_default_availability_list_payload_any( async def help_test_default_availability_list_single( hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -377,10 +380,10 @@ async def help_test_default_availability_list_single( ] config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) - entry.add_to_hass(hass) - with patch("homeassistant.config.load_yaml_config_file", return_value=config): - await entry.async_setup(hass) + with patch( + "homeassistant.config.load_yaml_config_file", return_value=config + ), suppress(vol.MultipleInvalid): + await mqtt_mock_entry() assert ( "two or more values in the same group of exclusion 'availability'" @@ -1635,9 +1638,9 @@ async def help_test_entity_category( entry = ent_registry.async_get(entity_id) assert entry is not None and entry.entity_category is None - # Discover an entity with entity category set to "config" + # Discover an entity with entity category set to "diagnostic" unique_id = "veryunique2" - config["entity_category"] = "config" + config["entity_category"] = EntityCategory.DIAGNOSTIC config["unique_id"] = unique_id data = json.dumps(config) async_fire_mqtt_message(hass, f"homeassistant/{domain}/{unique_id}/config", data) @@ -1645,7 +1648,7 @@ async def help_test_entity_category( entity_id = ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, unique_id) assert entity_id is not None and hass.states.get(entity_id) entry = ent_registry.async_get(entity_id) - assert entry is not None and entry.entity_category == "config" + assert entry is not None and entry.entity_category == EntityCategory.DIAGNOSTIC # Discover an entity with entity category set to "no_such_category" unique_id = "veryunique3" diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 74dc48f4402..f3bf92951b0 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -891,11 +891,9 @@ async def test_optimistic_position( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test optimistic position is not supported.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( - "Invalid config for [mqtt]: 'set_position_topic' must be set together with 'position_topic'" - in caplog.text + "'set_position_topic' must be set together with 'position_topic'" in caplog.text ) @@ -2663,9 +2661,8 @@ async def test_invalid_device_class( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the setting of an invalid device class.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: expected CoverDeviceClass" in caplog.text + assert await mqtt_mock_entry() + assert "expected CoverDeviceClass" in caplog.text async def test_setting_attribute_via_mqtt_json_message( @@ -3402,8 +3399,7 @@ async def test_set_position_topic_without_get_position_topic_error( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when set_position_topic is used without position_topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( f"'{CONF_SET_POSITION_TOPIC}' must be set together with '{CONF_GET_POSITION_TOPIC}'." ) in caplog.text @@ -3429,8 +3425,7 @@ async def test_value_template_without_state_topic_error( 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() + assert await mqtt_mock_entry() assert ( f"'{CONF_VALUE_TEMPLATE}' must be set together with '{CONF_STATE_TOPIC}'." ) in caplog.text @@ -3456,8 +3451,7 @@ async def test_position_template_without_position_topic_error( 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() + assert await mqtt_mock_entry() assert ( f"'{CONF_GET_POSITION_TEMPLATE}' must be set together with '{CONF_GET_POSITION_TOPIC}'." in caplog.text @@ -3484,8 +3478,7 @@ async def test_set_position_template_without_set_position_topic( 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() + assert await mqtt_mock_entry() assert ( f"'{CONF_SET_POSITION_TEMPLATE}' must be set together with '{CONF_SET_POSITION_TOPIC}'." in caplog.text @@ -3512,8 +3505,7 @@ async def test_tilt_command_template_without_tilt_command_topic( 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() + assert await mqtt_mock_entry() assert ( f"'{CONF_TILT_COMMAND_TEMPLATE}' must be set together with '{CONF_TILT_COMMAND_TOPIC}'." in caplog.text @@ -3540,8 +3532,7 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( 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() + assert await mqtt_mock_entry() assert ( f"'{CONF_TILT_STATUS_TEMPLATE}' must be set together with '{CONF_TILT_STATUS_TOPIC}'." in caplog.text diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 4d0b8457049..863a79fce70 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -122,28 +122,48 @@ async def test_invalid_json( assert not mock_dispatcher_send.called +@pytest.mark.parametrize("domain", [*list(mqtt.PLATFORMS), "device_automation", "tag"]) @pytest.mark.no_fail_on_log_exception -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_discovery_schema_error( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + domain: Platform | str, ) -> None: """Test unexpected error JSON config.""" with patch( - "homeassistant.components.mqtt.binary_sensor.DISCOVERY_SCHEMA", + f"homeassistant.components.mqtt.{domain}.DISCOVERY_SCHEMA", side_effect=AttributeError("Attribute abc not found"), ): await mqtt_mock_entry() async_fire_mqtt_message( hass, - "homeassistant/binary_sensor/bla/config", - '{"name": "Beer", "state_topic": "ok"}', + f"homeassistant/{domain}/bla/config", + '{"name": "Beer", "some_topic": "bla"}', ) await hass.async_block_till_done() assert "AttributeError: Attribute abc not found" in caplog.text +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) +async def test_invalid_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sending in JSON that violates the platform schema.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/alarm_control_panel/bla/config", + '{"name": "abc", "state_topic": "home/alarm", ' + '"command_topic": "home/alarm/set", ' + '"qos": "some_invalid_value"}', + ) + await hass.async_block_till_done() + assert "Error 'expected int for dictionary value @ data['qos']'" in caplog.text + + async def test_only_valid_components( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index 37a17ac9a41..4c0e63fec1f 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -224,11 +224,13 @@ async def test_default_availability_list_payload_any( async def test_default_availability_list_single( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test availability list and availability_topic are mutually exclusive.""" await help_test_default_availability_list_single( - hass, caplog, event.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, caplog, event.DOMAIN, DEFAULT_CONFIG ) @@ -271,11 +273,8 @@ async def test_invalid_device_class( caplog: pytest.LogCaptureFixture, ) -> None: """Test device_class option with invalid value.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: expected EventDeviceClass or one of" in caplog.text - ) + assert await mqtt_mock_entry() + assert "expected EventDeviceClass or one of" in caplog.text async def test_setting_attribute_via_mqtt_json_message( diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index fe354817aef..6642d778f53 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -96,9 +96,8 @@ async def test_fail_setup_if_no_command_topic( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test if command fails with command topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: required key not provided" in caplog.text + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @pytest.mark.parametrize( @@ -1584,7 +1583,7 @@ async def test_attributes( @pytest.mark.parametrize( - ("name", "hass_config", "success", "features"), + ("name", "hass_config", "success", "features", "error_message"), [ ( "test1", @@ -1598,6 +1597,7 @@ async def test_attributes( }, True, fan.FanEntityFeature(0), + None, ), ( "test2", @@ -1612,6 +1612,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.OSCILLATE, + None, ), ( "test3", @@ -1626,6 +1627,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.SET_SPEED, + None, ), ( "test4", @@ -1640,6 +1642,7 @@ async def test_attributes( }, False, None, + "some but not all values in the same group of inclusion 'preset_modes'", ), ( "test5", @@ -1655,6 +1658,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + None, ), ( "test6", @@ -1670,6 +1674,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + None, ), ( "test7", @@ -1684,6 +1689,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.SET_SPEED, + None, ), ( "test8", @@ -1699,6 +1705,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.OSCILLATE | fan.FanEntityFeature.SET_SPEED, + None, ), ( "test9", @@ -1714,6 +1721,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + None, ), ( "test10", @@ -1729,6 +1737,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + None, ), ( "test11", @@ -1745,6 +1754,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE | fan.FanEntityFeature.OSCILLATE, + None, ), ( "test12", @@ -1761,6 +1771,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.SET_SPEED, + None, ), ( "test13", @@ -1777,6 +1788,7 @@ async def test_attributes( }, False, None, + "not a valid value", ), ( "test14", @@ -1793,13 +1805,14 @@ async def test_attributes( }, False, None, + "not a valid value", ), ( "test15", { mqtt.DOMAIN: { fan.DOMAIN: { - "name": "test7reset_payload_in_preset_modes_a", + "name": "test15", "command_topic": "command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "preset_modes": ["auto", "smart", "normal", "None"], @@ -1808,6 +1821,7 @@ async def test_attributes( }, False, None, + "preset_modes must not contain payload_reset_preset_mode", ), ( "test16", @@ -1824,6 +1838,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + "some error", ), ( "test17", @@ -1838,25 +1853,27 @@ async def test_attributes( }, True, fan.FanEntityFeature.DIRECTION, + "some error", ), ], ) async def test_supported_features( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, name: str, success: bool, - features, + features: fan.FanEntityFeature | None, + error_message: str | None, ) -> None: """Test optimistic mode without state topic.""" + await mqtt_mock_entry() + state = hass.states.get(f"fan.{name}") + assert (state is not None) == success if success: - 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() + assert error_message in caplog.text @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 4d2637a264f..69e85e51d73 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -142,9 +142,8 @@ async def test_fail_setup_if_no_command_topic( caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: required key not provided" in caplog.text + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @pytest.mark.parametrize( @@ -934,11 +933,11 @@ async def test_attributes( @pytest.mark.parametrize( ("hass_config", "valid"), [ - ( + ( # test valid case 1 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_valid_1", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", } @@ -946,11 +945,11 @@ async def test_attributes( }, True, ), - ( + ( # test valid case 2 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_valid_2", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "device_class": "humidifier", @@ -959,11 +958,11 @@ async def test_attributes( }, True, ), - ( + ( # test valid case 3 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_valid_3", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "device_class": "dehumidifier", @@ -972,11 +971,11 @@ async def test_attributes( }, True, ), - ( + ( # test valid case 4 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_valid_4", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "device_class": None, @@ -985,11 +984,11 @@ async def test_attributes( }, True, ), - ( + ( # test invalid device_class { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_invalid_device_class", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "device_class": "notsupporedSpeci@l", @@ -998,11 +997,11 @@ async def test_attributes( }, False, ), - ( + ( # test mode_command_topic without modes { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_mode_command_without_modes", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "mode_command_topic": "mode-command-topic", @@ -1011,11 +1010,11 @@ async def test_attributes( }, False, ), - ( + ( # test invalid humidity min max case 1 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_invalid_humidity_min_max_1", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "min_humidity": 0, @@ -1025,11 +1024,11 @@ async def test_attributes( }, False, ), - ( + ( # test invalid humidity min max case 2 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_invalid_humidity_min_max_2", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "max_humidity": 20, @@ -1039,11 +1038,11 @@ async def test_attributes( }, False, ), - ( + ( # test invalid mode, is reset payload { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_invalid_mode_is_reset", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "mode_command_topic": "mode-command-topic", @@ -1061,11 +1060,9 @@ async def test_validity_configurations( valid: bool, ) -> None: """Test validity of configurations.""" - if valid: - await mqtt_mock_entry() - return - with pytest.raises(AssertionError): - await mqtt_mock_entry() + await mqtt_mock_entry() + state = hass.states.get("humidifier.test") + assert (state is not None) == valid @pytest.mark.parametrize( @@ -1167,14 +1164,11 @@ async def test_supported_features( features: humidifier.HumidifierEntityFeature | None, ) -> None: """Test supported features.""" + await mqtt_mock_entry() + state = hass.states.get(f"humidifier.{name}") + assert (state is not None) == success if success: - 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() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index 621be984b7b..5ca9bbbc297 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -1,6 +1,5 @@ """The tests for mqtt image component.""" from base64 import b64encode -from contextlib import suppress from http import HTTPStatus import json import ssl @@ -504,7 +503,7 @@ async def test_image_from_url_fails( } } }, - "Invalid config for [mqtt]: Expected one of [`image_topic`, `url_topic`], got none", + "Expected one of [`image_topic`, `url_topic`], got none", ), ], ) @@ -516,8 +515,7 @@ async def test_image_config_fails( error_msg: str, ) -> None: """Test setup with minimum configuration.""" - with suppress(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert error_msg in caplog.text diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index cd533cc6588..2aa8de388b1 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2096,7 +2096,9 @@ async def test_handle_message_callback( callbacks.append(args) mock_mqtt = await mqtt_mock_entry() - msg = ReceiveMessage("some-topic", b"test-payload", 1, False) + msg = ReceiveMessage( + "some-topic", b"test-payload", 1, False, "some-topic", datetime.now() + ) mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) await mqtt.async_subscribe(hass, "some-topic", _callback) mqtt_client_mock.on_message(mock_mqtt, None, msg) @@ -2123,35 +2125,30 @@ async def test_handle_message_callback( } ], ) -@patch("homeassistant.components.mqtt.PLATFORMS", []) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_setup_manual_mqtt_with_platform_key( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test set up a manual MQTT item with a platform key.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( - "Invalid config for [mqtt]: [platform] is an invalid option for [mqtt]" + "extra keys not allowed @ data['platform'] for manual configured MQTT light item" in caplog.text ) @pytest.mark.parametrize("hass_config", [{mqtt.DOMAIN: {"light": {"name": "test"}}}]) -@patch("homeassistant.components.mqtt.PLATFORMS", []) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_setup_manual_mqtt_with_invalid_config( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test set up a manual MQTT item with an invalid config.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: required key not provided @ data['mqtt'][0]['light'][0]['command_topic']. " - "Got None. (See ?, line ?)" in caplog.text - ) + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @patch("homeassistant.components.mqtt.PLATFORMS", []) @@ -3554,15 +3551,23 @@ async def test_publish_or_subscribe_without_valid_config_entry( await mqtt.async_subscribe(hass, "some-topic", record_calls, qos=0) -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) +@patch( + "homeassistant.components.mqtt.PLATFORMS", + [Platform.ALARM_CONTROL_PANEL, Platform.LIGHT], +) @pytest.mark.parametrize( "hass_config", [ { "mqtt": { - "light": [ - {"name": "test_new_modern", "command_topic": "test-topic_new"} - ] + "alarm_control_panel": [ + { + "name": "test", + "state_topic": "home/alarm", + "command_topic": "home/alarm/set", + }, + ], + "light": [{"name": "test", "command_topic": "test-topic_new"}], } } ], @@ -3576,9 +3581,19 @@ async def test_disabling_and_enabling_entry( await mqtt_mock_entry() entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] assert entry.state is ConfigEntryState.LOADED - # Late discovery of a light - config = '{"name": "abc", "command_topic": "test-topic"}' - async_fire_mqtt_message(hass, "homeassistant/light/abc/config", config) + # Late discovery of a mqtt entity + config_tag = '{"topic": "0AFFD2/tag_scanned", "value_template": "{{ value_json.PN532.UID }}"}' + config_alarm_control_panel = '{"name": "test_new", "state_topic": "home/alarm", "command_topic": "home/alarm/set"}' + config_light = '{"name": "test_new", "command_topic": "test-topic_new"}' + + # Discovery of mqtt tag + async_fire_mqtt_message(hass, "homeassistant/tag/abc/config", config_tag) + + # Late discovery of mqtt entities + async_fire_mqtt_message( + hass, "homeassistant/alarm_control_panel/abc/config", config_alarm_control_panel + ) + async_fire_mqtt_message(hass, "homeassistant/light/abc/config", config_light) # Disable MQTT config entry await hass.config_entries.async_set_disabled_by( @@ -3587,6 +3602,14 @@ async def test_disabling_and_enabling_entry( await hass.async_block_till_done() await hass.async_block_till_done() + assert ( + "MQTT integration is disabled, skipping setup of discovered item MQTT tag" + in caplog.text + ) + assert ( + "MQTT integration is disabled, skipping setup of discovered item MQTT alarm_control_panel" + in caplog.text + ) assert ( "MQTT integration is disabled, skipping setup of discovered item MQTT light" in caplog.text @@ -3602,7 +3625,8 @@ async def test_disabling_and_enabling_entry( new_mqtt_config_entry = entry assert new_mqtt_config_entry.state is ConfigEntryState.LOADED - assert hass.states.get("light.test_new_modern") is not None + assert hass.states.get("light.test") is not None + assert hass.states.get("alarm_control_panel.test") is not None @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) @@ -3950,3 +3974,39 @@ async def test_reload_with_invalid_config( # Test nothing changed as loading the config failed assert hass.states.get("sensor.test") is not None + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "sensor": { + "name": "test", + "state_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_with_empty_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reloading yaml config fails.""" + await mqtt_mock_entry() + assert hass.states.get("sensor.test") is not None + + # Reload with an empty config and assert again + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test") is None diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index c7d17ed47a0..61a27c287ac 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -642,12 +642,8 @@ async def test_missing_templates( caplog: pytest.LogCaptureFixture, ) -> None: """Test to make sure missing template is not allowed.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: some but not all values in the same group of inclusion" - in caplog.text - ) + assert await mqtt_mock_entry() + assert "some but not all values in the same group of inclusion" in caplog.text @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 58d37943403..7de6c08f269 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -253,9 +253,8 @@ async def test_fail_setup_if_no_command_topic( caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: required key not provided" in caplog.text + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 3b44f86460f..e7471829856 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -197,9 +197,8 @@ async def test_fail_setup_if_no_command_topic( caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails with no command topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: required key not provided" in caplog.text + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @pytest.mark.parametrize( @@ -217,12 +216,8 @@ async def test_fail_setup_if_color_mode_deprecated( 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() - assert ( - "Invalid config for [mqtt]: color_mode must not be combined with any of" - in caplog.text - ) + assert await mqtt_mock_entry() + assert "color_mode must not be combined with any of" in caplog.text @pytest.mark.parametrize( @@ -250,7 +245,7 @@ async def test_fail_setup_if_color_mode_deprecated( COLOR_MODES_CONFIG, ({"supported_color_modes": ["unknown"]},), ), - "Invalid config for [mqtt]: value must be one of [ None: """Test if setup fails if supported color modes is invalid.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert error in caplog.text @@ -525,7 +519,7 @@ async def test_controlling_state_via_topic( async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":null}') light_state = hass.states.get("light.test") - assert "color_temp" not in light_state.attributes + assert light_state.attributes.get("color_temp") is None async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "effect":"colorloop"}' @@ -983,8 +977,8 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["hs_color"] == (359, 78) assert state.attributes["rgb_color"] == (255, 56, 59) assert state.attributes["xy_color"] == (0.654, 0.301) - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator( @@ -1004,8 +998,8 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["hs_color"] == (30.118, 100.0) assert state.attributes["rgb_color"] == (255, 128, 0) assert state.attributes["xy_color"] == (0.611, 0.375) - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator('{"state": "ON", "color": {"r": 255, "g": 128, "b": 0} }'), @@ -1023,7 +1017,7 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["rgbw_color"] == (255, 128, 0, 123) assert state.attributes["hs_color"] == (30.0, 67.451) assert state.attributes["rgb_color"] == (255, 169, 83) - assert "rgbww_color" not in state.attributes + assert state.attributes["rgbww_color"] is None assert state.attributes["xy_color"] == (0.526, 0.393) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", @@ -1044,7 +1038,7 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["rgbww_color"] == (255, 128, 0, 45, 32) assert state.attributes["hs_color"] == (29.872, 92.157) assert state.attributes["rgb_color"] == (255, 137, 20) - assert "rgbw_color" not in state.attributes + assert state.attributes["rgbw_color"] is None assert state.attributes["xy_color"] == (0.596, 0.382) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", @@ -1067,8 +1061,8 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["hs_color"] == (196.471, 100.0) assert state.attributes["rgb_color"] == (0, 185, 255) assert state.attributes["xy_color"] == (0.123, 0.223) - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator( @@ -1085,11 +1079,11 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.state == STATE_ON assert state.attributes["brightness"] == 75 assert state.attributes["color_mode"] == "white" - assert "hs_color" not in state.attributes - assert "rgb_color" not in state.attributes - assert "xy_color" not in state.attributes - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["hs_color"] is None + assert state.attributes["rgb_color"] is None + assert state.attributes["xy_color"] is None + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator('{"state": "ON", "white": 75}'), @@ -1104,11 +1098,11 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.state == STATE_ON assert state.attributes["brightness"] == 60 assert state.attributes["color_mode"] == "white" - assert "hs_color" not in state.attributes - assert "rgb_color" not in state.attributes - assert "xy_color" not in state.attributes - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["hs_color"] is None + assert state.attributes["rgb_color"] is None + assert state.attributes["xy_color"] is None + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator('{"state": "ON", "white": 60}'), diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index c6590c71c4d..f69b6e0730a 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -826,8 +826,7 @@ async def test_invalid_min_max_attributes( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid min/max attributes.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text @@ -948,11 +947,9 @@ async def test_invalid_mode( valid: bool, ) -> None: """Test invalid mode.""" - if valid: - await mqtt_mock_entry() - return - with pytest.raises(AssertionError): - await mqtt_mock_entry() + await mqtt_mock_entry() + state = hass.states.get("number.test_number") + assert (state is not None) == valid @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 06967b7f8a8..0f1be02875c 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -708,11 +708,13 @@ async def test_default_availability_list_payload_any( async def test_default_availability_list_single( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test availability list and availability_topic are mutually exclusive.""" await help_test_default_availability_list_single( - hass, caplog, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, caplog, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -754,11 +756,8 @@ async def test_invalid_device_class( caplog: pytest.LogCaptureFixture, ) -> None: """Test device_class option with invalid value.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: expected SensorDeviceClass or one of" in caplog.text - ) + assert await mqtt_mock_entry() + assert "expected SensorDeviceClass or one of" in caplog.text @pytest.mark.parametrize( @@ -818,11 +817,8 @@ async def test_invalid_state_class( caplog: pytest.LogCaptureFixture, ) -> None: """Test state_class option with invalid value.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: expected SensorStateClass or one of" in caplog.text - ) + assert await mqtt_mock_entry() + assert "expected SensorStateClass or one of" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index bf6fe1b0130..80f38dffcf9 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -205,11 +205,13 @@ async def test_controlling_validation_state_via_topic( ], ) async def test_attribute_validation_max_greater_then_min( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the validation of min and max configuration attributes.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() + assert "not a valid value" in caplog.text @pytest.mark.parametrize( @@ -228,11 +230,13 @@ 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: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the max value of of max configuration attribute.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() + assert "not a valid value" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index 941072bc224..d1f265770b8 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -3,6 +3,7 @@ from collections.abc import Callable from pathlib import Path from random import getrandbits +import shutil import tempfile from unittest.mock import patch @@ -16,62 +17,94 @@ from tests.common import MockConfigEntry from tests.typing import MqttMockHAClient, MqttMockPahoClient +async def help_create_test_certificate_file( + hass: HomeAssistant, + mock_temp_dir: str, + option: str, + content: bytes = b"old content", +) -> None: + """Help creating a certificate test file.""" + temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir + + def _create_file() -> None: + if not temp_dir.exists(): + temp_dir.mkdir(0o700) + temp_file = temp_dir / option + with open(temp_file, "wb") as old_file: + old_file.write(content) + old_file.close() + + await hass.async_add_executor_job(_create_file) + + @pytest.mark.parametrize( - ("option", "content", "file_created"), + ("option", "content"), [ - (mqtt.CONF_CERTIFICATE, "auto", False), - (mqtt.CONF_CERTIFICATE, "### CA CERTIFICATE ###", True), - (mqtt.CONF_CLIENT_CERT, "### CLIENT CERTIFICATE ###", True), - (mqtt.CONF_CLIENT_KEY, "### PRIVATE KEY ###", True), + (mqtt.CONF_CERTIFICATE, "### CA CERTIFICATE ###"), + (mqtt.CONF_CLIENT_CERT, "### CLIENT CERTIFICATE ###"), + (mqtt.CONF_CLIENT_KEY, "### PRIVATE KEY ###"), ], ) -@pytest.mark.parametrize("temp_dir_prefix", ["create-test"]) +@pytest.mark.parametrize("temp_dir_prefix", ["create-test1"]) async def test_async_create_certificate_temp_files( hass: HomeAssistant, mock_temp_dir: str, option: str, content: str, - file_created: bool, ) -> None: """Test creating and reading and recovery certificate files.""" config = {option: content} - temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir - - # Create old file to be able to assert it is removed with auto option - def _ensure_old_file_exists() -> None: - if not temp_dir.exists(): - temp_dir.mkdir(0o700) - temp_file = temp_dir / option - with open(temp_file, "wb") as old_file: - old_file.write(b"old content") - old_file.close() - - await hass.async_add_executor_job(_ensure_old_file_exists) + # Create old file to be able to assert it is replaced and recovered + await help_create_test_certificate_file(hass, mock_temp_dir, option) await mqtt.util.async_create_certificate_temp_files(hass, config) file_path = await hass.async_add_executor_job(mqtt.util.get_file_path, option) - assert bool(file_path) is file_created + assert file_path is not None assert ( await hass.async_add_executor_job( - mqtt.util.migrate_certificate_file_to_content, file_path or content + mqtt.util.migrate_certificate_file_to_content, file_path ) == content ) - # Make sure certificate temp files are recovered - await hass.async_add_executor_job(_ensure_old_file_exists) + # Make sure old files are removed to test certificate and dir creation + def _remove_old_files() -> None: + temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir + shutil.rmtree(temp_dir) + await hass.async_add_executor_job(_remove_old_files) + + # Test a new dir and file is created correctly await mqtt.util.async_create_certificate_temp_files(hass, config) - file_path2 = await hass.async_add_executor_job(mqtt.util.get_file_path, option) - assert bool(file_path2) is file_created + file_path = await hass.async_add_executor_job(mqtt.util.get_file_path, option) + assert file_path is not None assert ( await hass.async_add_executor_job( - mqtt.util.migrate_certificate_file_to_content, file_path2 or content + mqtt.util.migrate_certificate_file_to_content, file_path ) == content ) - assert file_path == file_path2 + +@pytest.mark.parametrize("temp_dir_prefix", ["create-test2"]) +async def test_certificate_temp_files_with_auto_mode( + hass: HomeAssistant, + mock_temp_dir: str, +) -> None: + """Test creating and reading and recovery certificate files with auto mode.""" + config = {mqtt.CONF_CERTIFICATE: "auto"} + + # Create old file to be able to assert it is removed with auto option + await help_create_test_certificate_file(hass, mock_temp_dir, mqtt.CONF_CERTIFICATE) + await mqtt.util.async_create_certificate_temp_files(hass, config) + file_path = await hass.async_add_executor_job(mqtt.util.get_file_path, "auto") + assert file_path is None + assert ( + await hass.async_add_executor_job( + mqtt.util.migrate_certificate_file_to_content, "auto" + ) + == "auto" + ) async def test_reading_non_exitisting_certificate_file() -> None: diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index 852075c6527..381cddb2817 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -230,53 +230,164 @@ async def test_no_triggers( assert triggers == [] -async def test_fires_on_camera_motion(hass: HomeAssistant, calls) -> None: +async def test_fires_on_camera_motion( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test camera_motion triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_motion") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() - message = {"device_id": DEVICE_ID, "type": "camera_motion", "timestamp": utcnow()} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_motion") + + message = { + "device_id": device_entry.id, + "type": "camera_motion", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_fires_on_camera_person(hass: HomeAssistant, calls) -> None: +async def test_fires_on_camera_person( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test camera_person triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_person") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() - message = {"device_id": DEVICE_ID, "type": "camera_person", "timestamp": utcnow()} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_person") + + message = { + "device_id": device_entry.id, + "type": "camera_person", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_fires_on_camera_sound(hass: HomeAssistant, calls) -> None: - """Test camera_person triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_sound") +async def test_fires_on_camera_sound( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: + """Test camera_sound triggers firing.""" + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraSound": {}, + }, + ) + ) + await setup_platform() - message = {"device_id": DEVICE_ID, "type": "camera_sound", "timestamp": utcnow()} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_sound") + + message = { + "device_id": device_entry.id, + "type": "camera_sound", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_fires_on_doorbell_chime(hass: HomeAssistant, calls) -> None: +async def test_fires_on_doorbell_chime( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test doorbell_chime triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "doorbell_chime") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.DoorbellChime": {}, + }, + ) + ) + await setup_platform() - message = {"device_id": DEVICE_ID, "type": "doorbell_chime", "timestamp": utcnow()} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "doorbell_chime") + + message = { + "device_id": device_entry.id, + "type": "doorbell_chime", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_trigger_for_wrong_device_id(hass: HomeAssistant, calls) -> None: - """Test for turn_on and turn_off triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_motion") +async def test_trigger_for_wrong_device_id( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: + """Test messages for the wrong device are ignored.""" + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_motion") message = { "device_id": "wrong-device-id", @@ -288,12 +399,31 @@ async def test_trigger_for_wrong_device_id(hass: HomeAssistant, calls) -> None: assert len(calls) == 0 -async def test_trigger_for_wrong_event_type(hass: HomeAssistant, calls) -> None: - """Test for turn_on and turn_off triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_motion") +async def test_trigger_for_wrong_event_type( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: + """Test that messages for the wrong event type are ignored.""" + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_motion") message = { - "device_id": DEVICE_ID, + "device_id": device_entry.id, "type": "wrong-event-type", "timestamp": utcnow(), } diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index ecfe412bdbf..1e3eed91f19 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -107,11 +107,11 @@ async def test_setup_configuration_failure( @pytest.mark.parametrize("subscriber_side_effect", [SubscriberException()]) async def test_setup_susbcriber_failure( - hass: HomeAssistant, warning_caplog, failing_subscriber, setup_base_platform + hass: HomeAssistant, caplog, failing_subscriber, setup_base_platform ) -> None: """Test configuration error.""" await setup_base_platform() - assert "Subscriber error:" in warning_caplog.text + assert "Subscriber error:" in caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -119,7 +119,7 @@ async def test_setup_susbcriber_failure( async def test_setup_device_manager_failure( - hass: HomeAssistant, warning_caplog, setup_base_platform + hass: HomeAssistant, caplog, setup_base_platform ) -> None: """Test device manager api failure.""" with patch( @@ -130,7 +130,7 @@ async def test_setup_device_manager_failure( ): await setup_base_platform() - assert "Device manager error:" in warning_caplog.text + assert "Device manager error:" in caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index a3f7dfcb9d2..0776b80a3cd 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -92,7 +92,11 @@ async def simulate_webhook(hass, webhook_id, response): @contextmanager def selected_platforms(platforms): """Restrict loaded platforms to list given.""" - with patch("homeassistant.components.netatmo.PLATFORMS", platforms), patch( + with patch( + "homeassistant.components.netatmo.data_handler.PLATFORMS", platforms + ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", - ), patch("homeassistant.components.netatmo.webhook_generate_url"): + ), patch( + "homeassistant.components.netatmo.webhook_generate_url" + ): yield diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 8bfe7176f5d..e9a66cfefc8 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -478,7 +478,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", ["camera"] + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( @@ -491,7 +491,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert fake_post_hits == 11 + assert fake_post_hits == 8 async def test_camera_image_raises_exception( diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 6e4ae0e67cb..99000403a38 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -1,7 +1,9 @@ """The tests for the Netatmo climate platform.""" +from datetime import timedelta from unittest.mock import patch import pytest +from voluptuous.error import MultipleInvalid from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -18,11 +20,14 @@ from homeassistant.components.climate import ( ) from homeassistant.components.netatmo.climate import PRESET_FROST_GUARD, PRESET_SCHEDULE from homeassistant.components.netatmo.const import ( + ATTR_END_DATETIME, ATTR_SCHEDULE_NAME, + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, SERVICE_SET_SCHEDULE, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook @@ -458,6 +463,78 @@ async def test_service_schedule_thermostats( assert "summer is not a valid schedule" in caplog.text +async def test_service_preset_mode_with_end_time_thermostats( + hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth +) -> None: + """Test service for set preset mode with end datetime for Netatmo thermostats.""" + with selected_platforms(["climate"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + climate_entity_livingroom = "climate.livingroom" + + # Test setting a valid preset mode (that allow an end datetime in Netatmo == THERM_MODES) and a valid end datetime + await hass.services.async_call( + "netatmo", + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_PRESET_MODE: PRESET_AWAY, + ATTR_END_DATETIME: (dt_util.now() + timedelta(days=10)).strftime( + "%Y-%m-%d %H:%M:%S" + ), + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Fake webhook thermostat mode change to "Away" + response = { + "event_type": "therm_mode", + "home": {"id": "91763b24c43d3e344f424e8b", "therm_mode": "away"}, + "mode": "away", + "previous_mode": "schedule", + "push_type": "home_event_changed", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "auto" + assert ( + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" + ) + + # Test setting an invalid preset mode (not in THERM_MODES) and a valid end datetime + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + "netatmo", + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_PRESET_MODE: PRESET_BOOST, + ATTR_END_DATETIME: (dt_util.now() + timedelta(days=10)).strftime( + "%Y-%m-%d %H:%M:%S" + ), + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Test setting a valid preset mode (that allow an end datetime in Netatmo == THERM_MODES) without an end datetime + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + "netatmo", + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_PRESET_MODE: PRESET_AWAY, + }, + blocking=True, + ) + await hass.async_block_till_done() + + async def test_service_preset_mode_already_boost_valves( hass: HomeAssistant, config_entry, netatmo_auth ) -> None: diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index c6146dca339..e04295ae668 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -201,7 +201,7 @@ async def test_setup_with_cloud(hass: HomeAssistant, config_entry) -> None: ) as fake_delete_cloudhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", [] + "homeassistant.components.netatmo.data_handler.PLATFORMS", [] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( @@ -267,7 +267,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: ) as fake_delete_cloudhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", [] + "homeassistant.components.netatmo.data_handler.PLATFORMS", [] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 5a875097636..83218b6d6d1 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -99,7 +99,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", ["light"] + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( @@ -120,7 +120,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> ) await hass.async_block_till_done() - assert fake_post_hits == 4 + assert fake_post_hits == 3 assert hass.config_entries.async_entries(DOMAIN) assert len(hass.states.async_all()) == 0 diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index 071dd95fe7b..a4d04997e15 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -2,7 +2,9 @@ from collections.abc import Generator from copy import deepcopy from unittest.mock import MagicMock, patch +from urllib.error import HTTPError +from py_nextbus.client import NextBusFormatError, NextBusHTTPError, RouteStop import pytest from homeassistant.components import sensor @@ -12,10 +14,12 @@ from homeassistant.components.nextbus.const import ( CONF_STOP, DOMAIN, ) +from homeassistant.components.nextbus.coordinator import NextBusDataUpdateCoordinator from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -70,9 +74,7 @@ BASIC_RESULTS = { @pytest.fixture def mock_nextbus() -> Generator[MagicMock, None, None]: """Create a mock py_nextbus module.""" - with patch( - "homeassistant.components.nextbus.sensor.NextBusClient", - ) as client: + with patch("homeassistant.components.nextbus.coordinator.NextBusClient") as client: yield client @@ -89,7 +91,7 @@ def mock_nextbus_predictions( async def assert_setup_sensor( hass: HomeAssistant, - config: dict[str, str], + config: dict[str, dict[str, str]], expected_state=ConfigEntryState.LOADED, ) -> MockConfigEntry: """Set up the sensor and assert it's been created.""" @@ -144,9 +146,11 @@ async def test_verify_valid_state( ) -> None: """Verify all attributes are set from a valid response.""" await assert_setup_sensor(hass, CONFIG_BASIC) + entity = er.async_get(hass).async_get(SENSOR_ID) + assert entity mock_nextbus_predictions.assert_called_once_with( - [{"stop_tag": VALID_STOP, "route_tag": VALID_ROUTE}], VALID_AGENCY + {RouteStop(VALID_ROUTE, VALID_STOP)} ) state = hass.states.get(SENSOR_ID) @@ -272,6 +276,28 @@ async def test_direction_list( assert state.attributes["upcoming"] == "0, 1, 2, 3" +@pytest.mark.parametrize( + "client_exception", + ( + NextBusHTTPError("failed", HTTPError("url", 500, "error", MagicMock(), None)), + NextBusFormatError("failed"), + ), +) +async def test_prediction_exceptions( + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, + client_exception: Exception, +) -> None: + """Test that some coodinator exceptions raise UpdateFailed exceptions.""" + await assert_setup_sensor(hass, CONFIG_BASIC) + coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][VALID_AGENCY] + mock_nextbus_predictions.side_effect = client_exception + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + async def test_custom_name( hass: HomeAssistant, mock_nextbus: MagicMock, diff --git a/tests/components/nextdns/test_sensor.py b/tests/components/nextdns/test_sensor.py index c3c7577bd83..e500ff3c626 100644 --- a/tests/components/nextdns/test_sensor.py +++ b/tests/components/nextdns/test_sensor.py @@ -4,12 +4,7 @@ from unittest.mock import patch from nextdns import ApiError -from homeassistant.components.nextdns.const import DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,158 +15,12 @@ from . import DNSSEC, ENCRYPTION, IP_VERSIONS, PROTOCOLS, STATUS, init_integrati from tests.common import async_fire_time_changed -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test states of sensors.""" registry = er.async_get(hass) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh_queries", - suggested_object_id="fake_profile_dns_over_https_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh3_queries", - suggested_object_id="fake_profile_dns_over_http_3_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh_queries_ratio", - suggested_object_id="fake_profile_dns_over_https_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh3_queries_ratio", - suggested_object_id="fake_profile_dns_over_http_3_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doq_queries", - suggested_object_id="fake_profile_dns_over_quic_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doq_queries_ratio", - suggested_object_id="fake_profile_dns_over_quic_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_dot_queries", - suggested_object_id="fake_profile_dns_over_tls_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_dot_queries_ratio", - suggested_object_id="fake_profile_dns_over_tls_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_not_validated_queries", - suggested_object_id="fake_profile_dnssec_not_validated_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_validated_queries", - suggested_object_id="fake_profile_dnssec_validated_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_validated_queries_ratio", - suggested_object_id="fake_profile_dnssec_validated_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_encrypted_queries", - suggested_object_id="fake_profile_encrypted_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_encrypted_queries_ratio", - suggested_object_id="fake_profile_encrypted_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_ipv4_queries", - suggested_object_id="fake_profile_ipv4_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_ipv6_queries", - suggested_object_id="fake_profile_ipv6_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_ipv6_queries_ratio", - suggested_object_id="fake_profile_ipv6_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_tcp_queries", - suggested_object_id="fake_profile_tcp_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_tcp_queries_ratio", - suggested_object_id="fake_profile_tcp_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_udp_queries", - suggested_object_id="fake_profile_udp_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_udp_queries_ratio", - suggested_object_id="fake_profile_udp_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_unencrypted_queries", - suggested_object_id="fake_profile_unencrypted_queries", - disabled_by=None, - ) - await init_integration(hass) state = hass.states.get("sensor.fake_profile_dns_queries") @@ -425,38 +274,11 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry.unique_id == "xyz12_udp_queries_ratio" -async def test_availability(hass: HomeAssistant) -> None: +async def test_availability( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - registry = er.async_get(hass) - - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh_queries", - suggested_object_id="fake_profile_dns_over_https_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_validated_queries", - suggested_object_id="fake_profile_dnssec_validated_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_encrypted_queries", - suggested_object_id="fake_profile_encrypted_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_ipv4_queries", - suggested_object_id="fake_profile_ipv4_queries", - disabled_by=None, - ) + er.async_get(hass) await init_integration(hass) diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 33a3f804902..9a360a24b63 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -513,11 +513,11 @@ async def test_switch( assert entry assert entry.unique_id == "xyz12_block_twitch" - state = hass.states.get("switch.fake_profile_block_twitter") + state = hass.states.get("switch.fake_profile_block_x_formerly_twitter") assert state assert state.state == STATE_ON - entry = registry.async_get("switch.fake_profile_block_twitter") + entry = registry.async_get("switch.fake_profile_block_x_formerly_twitter") assert entry assert entry.unique_id == "xyz12_block_twitter" diff --git a/tests/components/nibe_heatpump/__init__.py b/tests/components/nibe_heatpump/__init__.py index 5446e289656..d2852ec42f5 100644 --- a/tests/components/nibe_heatpump/__init__.py +++ b/tests/components/nibe_heatpump/__init__.py @@ -2,12 +2,24 @@ from typing import Any +from nibe.heatpump import Model + from homeassistant.components.nibe_heatpump import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +MOCK_ENTRY_DATA = { + "model": None, + "ip_address": "127.0.0.1", + "listening_port": 9999, + "remote_read_port": 10000, + "remote_write_port": 10001, + "word_swap": True, + "connection_type": "nibegw", +} + async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> None: """Add entry and get the coordinator.""" @@ -17,3 +29,8 @@ async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED + + +async def async_add_model(hass: HomeAssistant, model: Model): + """Add entry of specific model.""" + await async_add_entry(hass, {**MOCK_ENTRY_DATA, "model": model.name}) diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index 2a4e2f80ff5..d7343eac69c 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -62,4 +62,15 @@ async def fixture_coils(mock_connection): mock_connection.read_coil = read_coil mock_connection.read_coils = read_coils - return coils + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.nibe_heatpump import HeatPump + + get_coils_original = HeatPump.get_coils + + def get_coils(x): + coils_data = get_coils_original(x) + return [coil for coil in coils_data if coil.address in coils] + + with patch.object(HeatPump, "get_coils", new=get_coils): + yield coils diff --git a/tests/components/nibe_heatpump/snapshots/test_number.ambr b/tests/components/nibe_heatpump/snapshots/test_number.ambr new file mode 100644 index 00000000000..d174c0cc059 --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_number.ambr @@ -0,0 +1,135 @@ +# serializer version: 1 +# name: test_update[Model.F1155-47011-number.heat_offset_s1_47011--10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1155 Heat Offset S1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heat_offset_s1_47011', + 'last_changed': , + 'last_updated': , + 'state': '-10.0', + }) +# --- +# name: test_update[Model.F1155-47011-number.heat_offset_s1_47011-10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1155 Heat Offset S1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heat_offset_s1_47011', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_update[Model.F1155-47062-number.heat_offset_s1_47011-None] + None +# --- +# name: test_update[Model.F750-47062-number.hw_charge_offset_47062--10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F750 HW charge offset', + 'max': 12.7, + 'min': -12.8, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.hw_charge_offset_47062', + 'last_changed': , + 'last_updated': , + 'state': '-10.0', + }) +# --- +# name: test_update[Model.F750-47062-number.hw_charge_offset_47062-10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F750 HW charge offset', + 'max': 12.7, + 'min': -12.8, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.hw_charge_offset_47062', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_update[Model.F750-47062-number.hw_charge_offset_47062-None] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F750 HW charge offset', + 'max': 12.7, + 'min': -12.8, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.hw_charge_offset_47062', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_update[Model.S320-40031-number.heating_offset_climate_system_1_40031--10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '-10.0', + }) +# --- +# name: test_update[Model.S320-40031-number.heating_offset_climate_system_1_40031-10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_update[Model.S320-40031-number.heating_offset_climate_system_1_40031-None] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index e4f90a59f67..755827fa128 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -17,20 +17,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import async_add_entry +from . import async_add_model from tests.common import async_fire_time_changed -MOCK_ENTRY_DATA = { - "model": None, - "ip_address": "127.0.0.1", - "listening_port": 9999, - "remote_read_port": 10000, - "remote_write_port": 10001, - "word_swap": True, - "connection_type": "nibegw", -} - @pytest.fixture(autouse=True) async def fixture_single_platform(): @@ -62,7 +52,7 @@ async def test_reset_button( coils[unit.alarm_reset] = 0 coils[unit.alarm] = 0 - await async_add_entry(hass, {**MOCK_ENTRY_DATA, "model": model.name}) + await async_add_model(hass, model) state = hass.states.get(entity_id) assert state diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py new file mode 100644 index 00000000000..5c4d7f4341b --- /dev/null +++ b/tests/components/nibe_heatpump/test_number.py @@ -0,0 +1,109 @@ +"""Test the Nibe Heat Pump config flow.""" +from typing import Any +from unittest.mock import AsyncMock, patch + +from nibe.coil import CoilData +from nibe.heatpump import Model +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant + +from . import async_add_model + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch("homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.NUMBER]): + yield + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "value"), + [ + # Tests for S series coils with min/max + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", 10), + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", -10), + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", None), + # Tests for F series coils with min/max + (Model.F1155, 47011, "number.heat_offset_s1_47011", 10), + (Model.F1155, 47011, "number.heat_offset_s1_47011", -10), + (Model.F1155, 47062, "number.heat_offset_s1_47011", None), + # Tests for F series coils without min/max + (Model.F750, 47062, "number.hw_charge_offset_47062", 10), + (Model.F750, 47062, "number.hw_charge_offset_47062", -10), + (Model.F750, 47062, "number.hw_charge_offset_47062", None), + ], +) +async def test_update( + hass: HomeAssistant, + model: Model, + entity_id: str, + address: int, + value: Any, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + coils[address] = value + + await async_add_model(hass, model) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state == snapshot + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "value"), + [ + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", 10), + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", -10), + (Model.F1155, 47011, "number.heat_offset_s1_47011", 10), + (Model.F1155, 47011, "number.heat_offset_s1_47011", -10), + (Model.F750, 47062, "number.hw_charge_offset_47062", 10), + ], +) +async def test_set_value( + hass: HomeAssistant, + mock_connection: AsyncMock, + model: Model, + entity_id: str, + address: int, + value: Any, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, +) -> None: + """Test setting of value.""" + coils[address] = 0 + + await async_add_model(hass, model) + + await hass.async_block_till_done() + assert hass.states.get(entity_id) + + # Write value + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + await hass.async_block_till_done() + + # Verify written + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.value == value diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index 1e0cfd5b391..17c63dd3394 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -137,9 +137,21 @@ async def test_get_action_no_state( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) @@ -155,7 +167,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_value", "value": 0.3, @@ -178,10 +190,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) @@ -197,7 +219,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_value", "value": 0.3, diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 3f612c421c8..601a34d4271 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.number import ( ATTR_MAX, ATTR_MIN, + ATTR_MODE, ATTR_STEP, ATTR_VALUE, DOMAIN, @@ -227,6 +228,12 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number.step == 1.0 assert number.unit_of_measurement is None assert number.value == 0.5 + assert number.capability_attributes == { + ATTR_MAX: 100.0, + ATTR_MIN: 0.0, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 1.0, + } number_2 = MockNumberEntity() number_2.hass = hass @@ -235,6 +242,12 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number_2.step == 0.1 assert number_2.unit_of_measurement == "native_cats" assert number_2.value == 0.5 + assert number_2.capability_attributes == { + ATTR_MAX: 0.5, + ATTR_MIN: -0.5, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 0.1, + } number_3 = MockNumberEntityAttr() number_3.hass = hass @@ -243,6 +256,12 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number_3.step == 100.0 assert number_3.unit_of_measurement == "native_dogs" assert number_3.value == 500.0 + assert number_3.capability_attributes == { + ATTR_MAX: 1000.0, + ATTR_MIN: -1000.0, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 100.0, + } number_4 = MockNumberEntityDescr() number_4.hass = hass @@ -251,6 +270,12 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number_4.step == 2.0 assert number_4.unit_of_measurement == "native_rabbits" assert number_4.value is None + assert number_4.capability_attributes == { + ATTR_MAX: 10.0, + ATTR_MIN: -10.0, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 2.0, + } async def test_sync_set_value(hass: HomeAssistant) -> None: diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 702196d4574..25d47b342c5 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -34,6 +34,7 @@ 'model': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -71,6 +72,7 @@ 'model': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -108,6 +110,7 @@ 'model': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -139,6 +142,7 @@ 'original_icon': None, 'original_name': 'Sensed A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_a', 'unique_id': '/12.111111111111/sensed.A', @@ -167,6 +171,7 @@ 'original_icon': None, 'original_name': 'Sensed B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_b', 'unique_id': '/12.111111111111/sensed.B', @@ -225,6 +230,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -262,6 +268,7 @@ 'model': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -287,6 +294,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -324,6 +332,7 @@ 'model': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -361,6 +370,7 @@ 'model': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -398,6 +408,7 @@ 'model': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -435,6 +446,7 @@ 'model': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -472,6 +484,7 @@ 'model': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -509,6 +522,7 @@ 'model': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -540,6 +554,7 @@ 'original_icon': None, 'original_name': 'Sensed 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_0', 'unique_id': '/29.111111111111/sensed.0', @@ -568,6 +583,7 @@ 'original_icon': None, 'original_name': 'Sensed 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_1', 'unique_id': '/29.111111111111/sensed.1', @@ -596,6 +612,7 @@ 'original_icon': None, 'original_name': 'Sensed 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_2', 'unique_id': '/29.111111111111/sensed.2', @@ -624,6 +641,7 @@ 'original_icon': None, 'original_name': 'Sensed 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_3', 'unique_id': '/29.111111111111/sensed.3', @@ -652,6 +670,7 @@ 'original_icon': None, 'original_name': 'Sensed 4', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_4', 'unique_id': '/29.111111111111/sensed.4', @@ -680,6 +699,7 @@ 'original_icon': None, 'original_name': 'Sensed 5', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_5', 'unique_id': '/29.111111111111/sensed.5', @@ -708,6 +728,7 @@ 'original_icon': None, 'original_name': 'Sensed 6', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_6', 'unique_id': '/29.111111111111/sensed.6', @@ -736,6 +757,7 @@ 'original_icon': None, 'original_name': 'Sensed 7', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_7', 'unique_id': '/29.111111111111/sensed.7', @@ -866,6 +888,7 @@ 'model': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -903,6 +926,7 @@ 'model': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -934,6 +958,7 @@ 'original_icon': None, 'original_name': 'Sensed A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_a', 'unique_id': '/3A.111111111111/sensed.A', @@ -962,6 +987,7 @@ 'original_icon': None, 'original_name': 'Sensed B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_b', 'unique_id': '/3A.111111111111/sensed.B', @@ -1020,6 +1046,7 @@ 'model': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1057,6 +1084,7 @@ 'model': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1094,6 +1122,7 @@ 'model': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1131,6 +1160,7 @@ 'model': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1168,6 +1198,7 @@ 'model': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1205,6 +1236,7 @@ 'model': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1242,6 +1274,7 @@ 'model': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1273,6 +1306,7 @@ 'original_icon': None, 'original_name': 'Hub short on branch 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_short_0', 'unique_id': '/EF.111111111113/hub/short.0', @@ -1301,6 +1335,7 @@ 'original_icon': None, 'original_name': 'Hub short on branch 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_short_1', 'unique_id': '/EF.111111111113/hub/short.1', @@ -1329,6 +1364,7 @@ 'original_icon': None, 'original_name': 'Hub short on branch 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_short_2', 'unique_id': '/EF.111111111113/hub/short.2', @@ -1357,6 +1393,7 @@ 'original_icon': None, 'original_name': 'Hub short on branch 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_short_3', 'unique_id': '/EF.111111111113/hub/short.3', diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 0664d7e5402..cbcf0d6234e 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -34,6 +34,7 @@ 'model': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -71,6 +72,7 @@ 'model': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -104,6 +106,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/10.111111111111/temperature', @@ -153,6 +156,7 @@ 'model': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -186,6 +190,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/temperature', @@ -216,6 +221,7 @@ 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/pressure', @@ -280,6 +286,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -313,6 +320,7 @@ 'original_icon': None, 'original_name': 'Counter A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'counter_a', 'unique_id': '/1D.111111111111/counter.A', @@ -343,6 +351,7 @@ 'original_icon': None, 'original_name': 'Counter B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'counter_b', 'unique_id': '/1D.111111111111/counter.B', @@ -405,6 +414,7 @@ 'model': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -430,6 +440,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -463,6 +474,7 @@ 'original_icon': None, 'original_name': 'Counter A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'counter_a', 'unique_id': '/1D.111111111111/counter.A', @@ -493,6 +505,7 @@ 'original_icon': None, 'original_name': 'Counter B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'counter_b', 'unique_id': '/1D.111111111111/counter.B', @@ -555,6 +568,7 @@ 'model': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -588,6 +602,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/22.111111111111/temperature', @@ -637,6 +652,7 @@ 'model': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -670,6 +686,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/temperature', @@ -700,6 +717,7 @@ 'original_icon': None, 'original_name': 'Humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/humidity', @@ -730,6 +748,7 @@ 'original_icon': None, 'original_name': 'HIH3600 humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih3600', 'unique_id': '/26.111111111111/HIH3600/humidity', @@ -760,6 +779,7 @@ 'original_icon': None, 'original_name': 'HIH4000 humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih4000', 'unique_id': '/26.111111111111/HIH4000/humidity', @@ -790,6 +810,7 @@ 'original_icon': None, 'original_name': 'HIH5030 humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih5030', 'unique_id': '/26.111111111111/HIH5030/humidity', @@ -820,6 +841,7 @@ 'original_icon': None, 'original_name': 'HTM1735 humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_htm1735', 'unique_id': '/26.111111111111/HTM1735/humidity', @@ -850,6 +872,7 @@ 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/B1-R1-A/pressure', @@ -880,6 +903,7 @@ 'original_icon': None, 'original_name': 'Illuminance', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/S3-R1-A/illuminance', @@ -910,6 +934,7 @@ 'original_icon': None, 'original_name': 'VAD voltage', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_vad', 'unique_id': '/26.111111111111/VAD', @@ -940,6 +965,7 @@ 'original_icon': None, 'original_name': 'VDD voltage', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_vdd', 'unique_id': '/26.111111111111/VDD', @@ -970,6 +996,7 @@ 'original_icon': None, 'original_name': 'VIS voltage difference', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_vis', 'unique_id': '/26.111111111111/vis', @@ -1169,6 +1196,7 @@ 'model': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1202,6 +1230,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/28.111111111111/temperature', @@ -1251,6 +1280,7 @@ 'model': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1284,6 +1314,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/28.222222222222/temperature', @@ -1333,6 +1364,7 @@ 'model': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1366,6 +1398,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/28.222222222223/temperature', @@ -1415,6 +1448,7 @@ 'model': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1452,6 +1486,7 @@ 'model': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1485,6 +1520,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/30.111111111111/temperature', @@ -1515,6 +1551,7 @@ 'original_icon': None, 'original_name': 'Thermocouple K temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thermocouple_temperature_k', 'unique_id': '/30.111111111111/typeX/temperature', @@ -1545,6 +1582,7 @@ 'original_icon': None, 'original_name': 'Voltage', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/30.111111111111/volt', @@ -1575,6 +1613,7 @@ 'original_icon': None, 'original_name': 'VIS voltage gradient', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_vis_gradient', 'unique_id': '/30.111111111111/vis', @@ -1669,6 +1708,7 @@ 'model': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1706,6 +1746,7 @@ 'model': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1739,6 +1780,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/3B.111111111111/temperature', @@ -1788,6 +1830,7 @@ 'model': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1821,6 +1864,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/42.111111111111/temperature', @@ -1870,6 +1914,7 @@ 'model': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1903,6 +1948,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/temperature', @@ -1933,6 +1979,7 @@ 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/pressure', @@ -1963,6 +2010,7 @@ 'original_icon': None, 'original_name': 'Illuminance', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/light', @@ -1993,6 +2041,7 @@ 'original_icon': None, 'original_name': 'Humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/humidity', @@ -2087,6 +2136,7 @@ 'model': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -2120,6 +2170,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/temperature', @@ -2150,6 +2201,7 @@ 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/pressure', @@ -2214,6 +2266,7 @@ 'model': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -2247,6 +2300,7 @@ 'original_icon': None, 'original_name': 'Humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/humidity_corrected', @@ -2277,6 +2331,7 @@ 'original_icon': None, 'original_name': 'Raw humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_raw', 'unique_id': '/EF.111111111111/humidity/humidity_raw', @@ -2307,6 +2362,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/temperature', @@ -2386,6 +2442,7 @@ 'model': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -2419,6 +2476,7 @@ 'original_icon': None, 'original_name': 'Wetness 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wetness_0', 'unique_id': '/EF.111111111112/moisture/sensor.0', @@ -2449,6 +2507,7 @@ 'original_icon': None, 'original_name': 'Wetness 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wetness_1', 'unique_id': '/EF.111111111112/moisture/sensor.1', @@ -2479,6 +2538,7 @@ 'original_icon': None, 'original_name': 'Moisture 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_2', 'unique_id': '/EF.111111111112/moisture/sensor.2', @@ -2509,6 +2569,7 @@ 'original_icon': None, 'original_name': 'Moisture 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_3', 'unique_id': '/EF.111111111112/moisture/sensor.3', @@ -2603,6 +2664,7 @@ 'model': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 55ea7be1fa6..e4d081a409b 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -34,6 +34,7 @@ 'model': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -65,6 +66,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio', 'unique_id': '/05.111111111111/PIO', @@ -111,6 +113,7 @@ 'model': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -148,6 +151,7 @@ 'model': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -179,6 +183,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_a', 'unique_id': '/12.111111111111/PIO.A', @@ -207,6 +212,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_b', 'unique_id': '/12.111111111111/PIO.B', @@ -235,6 +241,7 @@ 'original_icon': None, 'original_name': 'Latch A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_a', 'unique_id': '/12.111111111111/latch.A', @@ -263,6 +270,7 @@ 'original_icon': None, 'original_name': 'Latch B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_b', 'unique_id': '/12.111111111111/latch.B', @@ -345,6 +353,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -382,6 +391,7 @@ 'model': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -407,6 +417,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -444,6 +455,7 @@ 'model': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -481,6 +493,7 @@ 'model': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -512,6 +525,7 @@ 'original_icon': None, 'original_name': 'Current A/D control', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'iad', 'unique_id': '/26.111111111111/IAD', @@ -558,6 +572,7 @@ 'model': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -595,6 +610,7 @@ 'model': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -632,6 +648,7 @@ 'model': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -669,6 +686,7 @@ 'model': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -700,6 +718,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_0', 'unique_id': '/29.111111111111/PIO.0', @@ -728,6 +747,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_1', 'unique_id': '/29.111111111111/PIO.1', @@ -756,6 +776,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_2', 'unique_id': '/29.111111111111/PIO.2', @@ -784,6 +805,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_3', 'unique_id': '/29.111111111111/PIO.3', @@ -812,6 +834,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 4', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_4', 'unique_id': '/29.111111111111/PIO.4', @@ -840,6 +863,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 5', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_5', 'unique_id': '/29.111111111111/PIO.5', @@ -868,6 +892,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 6', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_6', 'unique_id': '/29.111111111111/PIO.6', @@ -896,6 +921,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 7', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_7', 'unique_id': '/29.111111111111/PIO.7', @@ -924,6 +950,7 @@ 'original_icon': None, 'original_name': 'Latch 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_0', 'unique_id': '/29.111111111111/latch.0', @@ -952,6 +979,7 @@ 'original_icon': None, 'original_name': 'Latch 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_1', 'unique_id': '/29.111111111111/latch.1', @@ -980,6 +1008,7 @@ 'original_icon': None, 'original_name': 'Latch 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_2', 'unique_id': '/29.111111111111/latch.2', @@ -1008,6 +1037,7 @@ 'original_icon': None, 'original_name': 'Latch 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_3', 'unique_id': '/29.111111111111/latch.3', @@ -1036,6 +1066,7 @@ 'original_icon': None, 'original_name': 'Latch 4', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_4', 'unique_id': '/29.111111111111/latch.4', @@ -1064,6 +1095,7 @@ 'original_icon': None, 'original_name': 'Latch 5', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_5', 'unique_id': '/29.111111111111/latch.5', @@ -1092,6 +1124,7 @@ 'original_icon': None, 'original_name': 'Latch 6', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_6', 'unique_id': '/29.111111111111/latch.6', @@ -1120,6 +1153,7 @@ 'original_icon': None, 'original_name': 'Latch 7', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_7', 'unique_id': '/29.111111111111/latch.7', @@ -1346,6 +1380,7 @@ 'model': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1383,6 +1418,7 @@ 'model': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1414,6 +1450,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_a', 'unique_id': '/3A.111111111111/PIO.A', @@ -1442,6 +1479,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_b', 'unique_id': '/3A.111111111111/PIO.B', @@ -1500,6 +1538,7 @@ 'model': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1537,6 +1576,7 @@ 'model': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1574,6 +1614,7 @@ 'model': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1611,6 +1652,7 @@ 'model': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1648,6 +1690,7 @@ 'model': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1685,6 +1728,7 @@ 'model': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1716,6 +1760,7 @@ 'original_icon': None, 'original_name': 'Leaf sensor 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_0', 'unique_id': '/EF.111111111112/moisture/is_leaf.0', @@ -1744,6 +1789,7 @@ 'original_icon': None, 'original_name': 'Leaf sensor 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_1', 'unique_id': '/EF.111111111112/moisture/is_leaf.1', @@ -1772,6 +1818,7 @@ 'original_icon': None, 'original_name': 'Leaf sensor 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_2', 'unique_id': '/EF.111111111112/moisture/is_leaf.2', @@ -1800,6 +1847,7 @@ 'original_icon': None, 'original_name': 'Leaf sensor 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_3', 'unique_id': '/EF.111111111112/moisture/is_leaf.3', @@ -1828,6 +1876,7 @@ 'original_icon': None, 'original_name': 'Moisture sensor 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_0', 'unique_id': '/EF.111111111112/moisture/is_moisture.0', @@ -1856,6 +1905,7 @@ 'original_icon': None, 'original_name': 'Moisture sensor 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_1', 'unique_id': '/EF.111111111112/moisture/is_moisture.1', @@ -1884,6 +1934,7 @@ 'original_icon': None, 'original_name': 'Moisture sensor 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_2', 'unique_id': '/EF.111111111112/moisture/is_moisture.2', @@ -1912,6 +1963,7 @@ 'original_icon': None, 'original_name': 'Moisture sensor 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_3', 'unique_id': '/EF.111111111112/moisture/is_moisture.3', @@ -2042,6 +2094,7 @@ 'model': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -2073,6 +2126,7 @@ 'original_icon': None, 'original_name': 'Hub branch 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_0', 'unique_id': '/EF.111111111113/hub/branch.0', @@ -2101,6 +2155,7 @@ 'original_icon': None, 'original_name': 'Hub branch 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_1', 'unique_id': '/EF.111111111113/hub/branch.1', @@ -2129,6 +2184,7 @@ 'original_icon': None, 'original_name': 'Hub branch 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_2', 'unique_id': '/EF.111111111113/hub/branch.2', @@ -2157,6 +2213,7 @@ 'original_icon': None, 'original_name': 'Hub branch 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_3', 'unique_id': '/EF.111111111113/hub/branch.3', diff --git a/tests/components/opensky/__init__.py b/tests/components/opensky/__init__.py index e746521c72c..0f24f8931af 100644 --- a/tests/components/opensky/__init__.py +++ b/tests/components/opensky/__init__.py @@ -1,10 +1,9 @@ """Opensky tests.""" -import json from unittest.mock import patch from python_opensky import StatesResponse -from tests.common import load_fixture +from tests.common import load_json_object_fixture def patch_setup_entry() -> bool: @@ -16,5 +15,5 @@ def patch_setup_entry() -> bool: def get_states_response_fixture(fixture: str) -> StatesResponse: """Return the states response from json.""" - json_fixture = load_fixture(fixture) - return StatesResponse.parse_obj(json.loads(json_fixture)) + states_json = load_json_object_fixture(fixture) + return StatesResponse.from_api(states_json) diff --git a/tests/components/opensky/conftest.py b/tests/components/opensky/conftest.py index f74c18773f5..90e0b7251bf 100644 --- a/tests/components/opensky/conftest.py +++ b/tests/components/opensky/conftest.py @@ -1,10 +1,8 @@ """Configure tests for the OpenSky integration.""" from collections.abc import Awaitable, Callable -import json from unittest.mock import patch import pytest -from python_opensky import StatesResponse from homeassistant.components.opensky.const import ( CONF_ALTITUDE, @@ -21,7 +19,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_fixture +from . import get_states_response_fixture + +from tests.common import MockConfigEntry ComponentSetup = Callable[[MockConfigEntry], Awaitable[None]] @@ -88,10 +88,9 @@ async def mock_setup_integration( async def func(mock_config_entry: MockConfigEntry) -> None: mock_config_entry.add_to_hass(hass) - json_fixture = load_fixture("opensky/states.json") with patch( "python_opensky.OpenSky.get_states", - return_value=StatesResponse.parse_obj(json.loads(json_fixture)), + return_value=get_states_response_fixture("opensky/states.json"), ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index b637a0d0356..3429d5eec7e 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -1,10 +1,8 @@ """OpenSky sensor tests.""" from datetime import timedelta -import json from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory -from python_opensky import StatesResponse from syrupy import SnapshotAssertion from homeassistant.components.opensky.const import ( @@ -18,19 +16,19 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from . import get_states_response_fixture from .conftest import ComponentSetup -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import MockConfigEntry, async_fire_time_changed LEGACY_CONFIG = {Platform.SENSOR: [{CONF_PLATFORM: DOMAIN, CONF_RADIUS: 10.0}]} async def test_legacy_migration(hass: HomeAssistant) -> None: """Test migration from yaml to config flow.""" - json_fixture = load_fixture("opensky/states.json") with patch( "python_opensky.OpenSky.get_states", - return_value=StatesResponse.parse_obj(json.loads(json_fixture)), + return_value=get_states_response_fixture("opensky/states.json"), ): assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) await hass.async_block_till_done() @@ -85,10 +83,6 @@ async def test_sensor_updating( """Test updating sensor.""" await setup_integration(config_entry) - def get_states_response_fixture(fixture: str) -> StatesResponse: - json_fixture = load_fixture(fixture) - return StatesResponse.parse_obj(json.loads(json_fixture)) - events = [] async def event_listener(event: Event) -> None: diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py new file mode 100644 index 00000000000..774f3c9a79a --- /dev/null +++ b/tests/components/overkiz/test_init.py @@ -0,0 +1,89 @@ +"""Tests for Overkiz integration init.""" +from homeassistant.components.overkiz.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_HUB, TEST_PASSWORD + +from tests.common import MockConfigEntry, mock_registry + +ENTITY_SENSOR_DISCRETE_RSSI_LEVEL = "sensor.zipscreen_woonkamer_discrete_rssi_level" +ENTITY_ALARM_CONTROL_PANEL = "alarm_control_panel.alarm" +ENTITY_SWITCH_GARAGE = "switch.garage" +ENTITY_SENSOR_TARGET_CLOSURE_STATE = "sensor.zipscreen_woonkamer_target_closure_state" +ENTITY_SENSOR_TARGET_CLOSURE_STATE_2 = ( + "sensor.zipscreen_woonkamer_target_closure_state_2" +) + + +async def test_unique_id_migration(hass: HomeAssistant) -> None: + """Test migration of sensor unique IDs.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + mock_entry.add_to_hass(hass) + + mock_registry( + hass, + { + # This entity will be migrated to "io://1234-5678-1234/3541212-core:DiscreteRSSILevelState" + ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: er.RegistryEntry( + entity_id=ENTITY_SENSOR_DISCRETE_RSSI_LEVEL, + unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_DISCRETE_RSSI_LEVEL", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will be migrated to "internal://1234-5678-1234/alarm/0-TSKAlarmController" + ENTITY_ALARM_CONTROL_PANEL: er.RegistryEntry( + entity_id=ENTITY_ALARM_CONTROL_PANEL, + unique_id="internal://1234-5678-1234/alarm/0-UIWidget.TSKALARM_CONTROLLER", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will be migrated to "io://1234-5678-1234/0-OnOff" + ENTITY_SWITCH_GARAGE: er.RegistryEntry( + entity_id=ENTITY_SWITCH_GARAGE, + unique_id="io://1234-5678-1234/0-UIClass.ON_OFF", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will be removed since "io://1234-5678-1234/3541212-core:TargetClosureState" already exists + ENTITY_SENSOR_TARGET_CLOSURE_STATE: er.RegistryEntry( + entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE, + unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_TARGET_CLOSURE", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will not be migrated" + ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: er.RegistryEntry( + entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE_2, + unique_id="io://1234-5678-1234/3541212-core:TargetClosureState", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + }, + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ent_reg = er.async_get(hass) + + unique_id_map = { + ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: "io://1234-5678-1234/3541212-core:DiscreteRSSILevelState", + ENTITY_ALARM_CONTROL_PANEL: "internal://1234-5678-1234/alarm/0-TSKAlarmController", + ENTITY_SWITCH_GARAGE: "io://1234-5678-1234/0-OnOff", + ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: "io://1234-5678-1234/3541212-core:TargetClosureState", + } + + # Test if entities will be removed + assert set(ent_reg.entities.keys()) == set(unique_id_map) + + # Test if unique ids are migrated + for entity_id, unique_id in unique_id_map.items(): + entry = ent_reg.async_get(entity_id) + assert entry.unique_id == unique_id diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py index 95ff762ef72..a649240bd21 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -59,7 +59,7 @@ async def test_form(hass: HomeAssistant, picnic_api) -> None: await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "Teststreet 123b" + assert result2["title"] == "Picnic" assert result2["data"] == { CONF_ACCESS_TOKEN: picnic_api().session.auth_token, CONF_COUNTRY_CODE: "NL", diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index c47226d407e..cae10320fb9 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -9,7 +9,7 @@ import requests from homeassistant import config_entries from homeassistant.components.picnic import const -from homeassistant.components.picnic.const import CONF_COUNTRY_CODE +from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, DOMAIN from homeassistant.components.picnic.sensor import SENSOR_TYPES from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( @@ -17,6 +17,7 @@ from homeassistant.const import ( CURRENCY_EURO, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util @@ -168,8 +169,11 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): """Enable all sensors of the Picnic integration.""" # Enable the sensors for sensor_type in SENSOR_KEYS: + entry = self.entity_registry.async_get_or_create( + Platform.SENSOR, DOMAIN, f"{self.config_entry.unique_id}.{sensor_type}" + ) updated_entry = self.entity_registry.async_update_entity( - f"sensor.picnic_{sensor_type}", disabled_by=None + entry.entity_id, disabled_by=None ) assert updated_entry.disabled is False await self.hass.async_block_till_done() @@ -197,76 +201,86 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): # Assert that sensors are not set up assert ( - self.hass.states.get("sensor.picnic_selected_slot_max_order_time") is None + self.hass.states.get("sensor.mock_title_max_order_time_of_selected_slot") + is None + ) + assert self.hass.states.get("sensor.mock_title_status_of_last_order") is None + assert ( + self.hass.states.get("sensor.mock_title_total_price_of_last_order") is None ) - assert self.hass.states.get("sensor.picnic_last_order_status") is None - assert self.hass.states.get("sensor.picnic_last_order_total_price") is None async def test_sensors_setup(self): """Test the default sensor setup behaviour.""" await self._setup_platform(use_default_responses=True) - self._assert_sensor("sensor.picnic_cart_items_count", "10") + self._assert_sensor("sensor.mock_title_cart_items_count", "10") self._assert_sensor( - "sensor.picnic_cart_total_price", "25.35", unit=CURRENCY_EURO + "sensor.mock_title_cart_total_price", + "25.35", + unit=CURRENCY_EURO, ) self._assert_sensor( - "sensor.picnic_selected_slot_start", + "sensor.mock_title_start_of_selected_slot", "2021-03-03T13:45:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_selected_slot_end", + "sensor.mock_title_end_of_selected_slot", "2021-03-03T14:45:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_selected_slot_max_order_time", + "sensor.mock_title_max_order_time_of_selected_slot", "2021-03-02T21:00:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) - self._assert_sensor("sensor.picnic_selected_slot_min_order_value", "35.0") self._assert_sensor( - "sensor.picnic_last_order_slot_start", + "sensor.mock_title_minimum_order_value_for_selected_slot", + "35.0", + ) + self._assert_sensor( + "sensor.mock_title_start_of_last_order_s_slot", "2021-02-26T19:15:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_last_order_slot_end", + "sensor.mock_title_end_of_last_order_s_slot", "2021-02-26T20:15:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) - self._assert_sensor("sensor.picnic_last_order_status", "COMPLETED") + self._assert_sensor("sensor.mock_title_status_of_last_order", "COMPLETED") self._assert_sensor( - "sensor.picnic_last_order_max_order_time", + "sensor.mock_title_max_order_time_of_last_order", "2021-02-25T21:00:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_last_order_delivery_time", + "sensor.mock_title_last_order_delivery_time", "2021-02-26T19:54:05+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_last_order_total_price", "41.33", unit=CURRENCY_EURO + "sensor.mock_title_total_price_of_last_order", + "41.33", + unit=CURRENCY_EURO, ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_start", + "sensor.mock_title_expected_start_of_next_delivery", "unknown", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_end", + "sensor.mock_title_expected_end_of_next_delivery", "unknown", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_start", + "sensor.mock_title_start_of_next_delivery_s_slot", "unknown", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_end", + "sensor.mock_title_end_of_next_delivery_s_slot", "unknown", cls=SensorDeviceClass.TIMESTAMP, ) @@ -275,13 +289,22 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): """Test that some sensors are disabled by default.""" await self._setup_platform(use_default_responses=True, enable_all_sensors=False) - self._assert_sensor("sensor.picnic_cart_items_count", disabled=True) - self._assert_sensor("sensor.picnic_last_order_slot_start", disabled=True) - self._assert_sensor("sensor.picnic_last_order_slot_end", disabled=True) - self._assert_sensor("sensor.picnic_last_order_status", disabled=True) - self._assert_sensor("sensor.picnic_last_order_total_price", disabled=True) - self._assert_sensor("sensor.picnic_next_delivery_slot_start", disabled=True) - self._assert_sensor("sensor.picnic_next_delivery_slot_end", disabled=True) + self._assert_sensor("sensor.mock_title_cart_items_count", disabled=True) + self._assert_sensor( + "sensor.mock_title_start_of_last_order_s_slot", disabled=True + ) + self._assert_sensor("sensor.mock_title_end_of_last_order_s_slot", disabled=True) + self._assert_sensor("sensor.mock_title_status_of_last_order", disabled=True) + self._assert_sensor( + "sensor.mock_title_total_price_of_last_order", disabled=True + ) + self._assert_sensor( + "sensor.mock_title_start_of_next_delivery_s_slot", + disabled=True, + ) + self._assert_sensor( + "sensor.mock_title_end_of_next_delivery_s_slot", disabled=True + ) async def test_sensors_no_selected_time_slot(self): """Test sensor states with no explicit selected time slot.""" @@ -299,11 +322,15 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): await self._setup_platform() # Assert sensors are unknown - self._assert_sensor("sensor.picnic_selected_slot_start", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_selected_slot_end", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_selected_slot_max_order_time", STATE_UNKNOWN) + self._assert_sensor("sensor.mock_title_start_of_selected_slot", STATE_UNKNOWN) + self._assert_sensor("sensor.mock_title_end_of_selected_slot", STATE_UNKNOWN) self._assert_sensor( - "sensor.picnic_selected_slot_min_order_value", STATE_UNKNOWN + "sensor.mock_title_max_order_time_of_selected_slot", + STATE_UNKNOWN, + ) + self._assert_sensor( + "sensor.mock_title_minimum_order_value_for_selected_slot", + STATE_UNKNOWN, ) async def test_next_delivery_sensors(self): @@ -321,18 +348,22 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): await self._setup_platform() # Assert delivery time is not available, but eta is - self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNKNOWN) + self._assert_sensor("sensor.mock_title_last_order_delivery_time", STATE_UNKNOWN) self._assert_sensor( - "sensor.picnic_next_delivery_eta_start", "2021-02-26T19:54:00+00:00" + "sensor.mock_title_expected_start_of_next_delivery", + "2021-02-26T19:54:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_end", "2021-02-26T20:14:00+00:00" + "sensor.mock_title_expected_end_of_next_delivery", + "2021-02-26T20:14:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_start", "2021-02-26T19:15:00+00:00" + "sensor.mock_title_start_of_next_delivery_s_slot", + "2021-02-26T19:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_end", "2021-02-26T20:15:00+00:00" + "sensor.mock_title_end_of_next_delivery_s_slot", + "2021-02-26T20:15:00+00:00", ) async def test_sensors_eta_date_malformed(self): @@ -352,8 +383,14 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): await self._coordinator.async_refresh() # Assert eta times are not available due to malformed date strings - self._assert_sensor("sensor.picnic_next_delivery_eta_start", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_next_delivery_eta_end", STATE_UNKNOWN) + self._assert_sensor( + "sensor.mock_title_expected_start_of_next_delivery", + STATE_UNKNOWN, + ) + self._assert_sensor( + "sensor.mock_title_expected_end_of_next_delivery", + STATE_UNKNOWN, + ) async def test_sensors_use_detailed_eta_if_available(self): """Test sensor states when last order is not yet delivered.""" @@ -378,10 +415,12 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): delivery_response["delivery_id"] ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_start", "2021-03-05T10:19:20+00:00" + "sensor.mock_title_expected_start_of_next_delivery", + "2021-03-05T10:19:20+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_end", "2021-03-05T10:39:20+00:00" + "sensor.mock_title_expected_end_of_next_delivery", + "2021-03-05T10:39:20+00:00", ) async def test_sensors_no_data(self): @@ -398,21 +437,35 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): # Assert all default-enabled sensors have STATE_UNAVAILABLE because the last update failed assert self._coordinator.last_update_success is False - self._assert_sensor("sensor.picnic_cart_total_price", STATE_UNAVAILABLE) - self._assert_sensor("sensor.picnic_selected_slot_start", STATE_UNAVAILABLE) - self._assert_sensor("sensor.picnic_selected_slot_end", STATE_UNAVAILABLE) + self._assert_sensor("sensor.mock_title_cart_total_price", STATE_UNAVAILABLE) self._assert_sensor( - "sensor.picnic_selected_slot_max_order_time", STATE_UNAVAILABLE + "sensor.mock_title_start_of_selected_slot", STATE_UNAVAILABLE + ) + self._assert_sensor("sensor.mock_title_end_of_selected_slot", STATE_UNAVAILABLE) + self._assert_sensor( + "sensor.mock_title_max_order_time_of_selected_slot", + STATE_UNAVAILABLE, ) self._assert_sensor( - "sensor.picnic_selected_slot_min_order_value", STATE_UNAVAILABLE + "sensor.mock_title_minimum_order_value_for_selected_slot", + STATE_UNAVAILABLE, ) self._assert_sensor( - "sensor.picnic_last_order_max_order_time", STATE_UNAVAILABLE + "sensor.mock_title_max_order_time_of_last_order", + STATE_UNAVAILABLE, + ) + self._assert_sensor( + "sensor.mock_title_last_order_delivery_time", + STATE_UNAVAILABLE, + ) + self._assert_sensor( + "sensor.mock_title_expected_start_of_next_delivery", + STATE_UNAVAILABLE, + ) + self._assert_sensor( + "sensor.mock_title_expected_end_of_next_delivery", + STATE_UNAVAILABLE, ) - self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNAVAILABLE) - self._assert_sensor("sensor.picnic_next_delivery_eta_start", STATE_UNAVAILABLE) - self._assert_sensor("sensor.picnic_next_delivery_eta_end", STATE_UNAVAILABLE) async def test_sensors_malformed_delivery_data(self): """Test sensor states when the delivery api returns not a list.""" @@ -425,10 +478,19 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): # Assert all last-order sensors have STATE_UNAVAILABLE because the delivery info fetch failed assert self._coordinator.last_update_success is True - self._assert_sensor("sensor.picnic_last_order_max_order_time", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_next_delivery_eta_start", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_next_delivery_eta_end", STATE_UNKNOWN) + self._assert_sensor( + "sensor.mock_title_max_order_time_of_last_order", + STATE_UNKNOWN, + ) + self._assert_sensor("sensor.mock_title_last_order_delivery_time", STATE_UNKNOWN) + self._assert_sensor( + "sensor.mock_title_expected_start_of_next_delivery", + STATE_UNKNOWN, + ) + self._assert_sensor( + "sensor.mock_title_expected_end_of_next_delivery", + STATE_UNKNOWN, + ) async def test_sensors_malformed_response(self): """Test coordinator update fails when API yields ValueError.""" @@ -474,22 +536,28 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): await self._setup_platform() self._assert_sensor( - "sensor.picnic_last_order_slot_start", "2022-03-08T12:15:00+00:00" + "sensor.mock_title_start_of_last_order_s_slot", + "2022-03-08T12:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_last_order_slot_end", "2022-03-08T13:15:00+00:00" + "sensor.mock_title_end_of_last_order_s_slot", + "2022-03-08T13:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_start", "2022-03-01T08:15:00+00:00" + "sensor.mock_title_start_of_next_delivery_s_slot", + "2022-03-01T08:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_end", "2022-03-01T09:15:00+00:00" + "sensor.mock_title_end_of_next_delivery_s_slot", + "2022-03-01T09:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_start", "2022-03-01T08:30:00+00:00" + "sensor.mock_title_expected_start_of_next_delivery", + "2022-03-01T08:30:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_end", "2022-03-01T08:45:00+00:00" + "sensor.mock_title_expected_end_of_next_delivery", + "2022-03-01T08:45:00+00:00", ) async def test_device_registry_entry(self): @@ -502,7 +570,7 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): identifiers={(const.DOMAIN, DEFAULT_USER_RESPONSE["user_id"])} ) assert picnic_service.model == DEFAULT_USER_RESPONSE["user_id"] - assert picnic_service.name == "Picnic: Commonstreet 123a" + assert picnic_service.name == "Mock Title" assert picnic_service.entry_type is dr.DeviceEntryType.SERVICE async def test_auth_token_is_saved_on_update(self): diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 78a3b7387ea..92818633df4 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -396,6 +396,24 @@ def hubs_music_library_fixture(): return load_fixture("plex/hubs_library_section.xml") +@pytest.fixture(name="update_check_nochange", scope="session") +def update_check_fixture_nochange() -> str: + """Load a no-change update resource payload and return it.""" + return load_fixture("plex/release_nochange.xml") + + +@pytest.fixture(name="update_check_new", scope="session") +def update_check_fixture_new() -> str: + """Load a changed update resource payload and return it.""" + return load_fixture("plex/release_new.xml") + + +@pytest.fixture(name="update_check_new_not_updatable", scope="session") +def update_check_fixture_new_not_updatable() -> str: + """Load a changed update resource payload (not updatable) and return it.""" + return load_fixture("plex/release_new_not_updatable.xml") + + @pytest.fixture(name="entry") async def mock_config_entry(): """Return the default mocked config entry.""" @@ -452,6 +470,7 @@ def mock_plex_calls( plex_server_clients, plex_server_default, security_token, + update_check_nochange, ): """Mock Plex API calls.""" requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users) @@ -519,6 +538,8 @@ def mock_plex_calls( requests_mock.get(f"{url}/playlists", text=playlists) requests_mock.get(f"{url}/playlists/500/items", text=playlist_500) requests_mock.get(f"{url}/security/token", text=security_token) + requests_mock.put(f"{url}/updater/check") + requests_mock.get(f"{url}/updater/status", text=update_check_nochange) @pytest.fixture diff --git a/tests/components/plex/fixtures/release_new.xml b/tests/components/plex/fixtures/release_new.xml new file mode 100644 index 00000000000..4fd2b1e99f4 --- /dev/null +++ b/tests/components/plex/fixtures/release_new.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/components/plex/fixtures/release_new_not_updatable.xml b/tests/components/plex/fixtures/release_new_not_updatable.xml new file mode 100644 index 00000000000..c83be0b964c --- /dev/null +++ b/tests/components/plex/fixtures/release_new_not_updatable.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/components/plex/fixtures/release_nochange.xml b/tests/components/plex/fixtures/release_nochange.xml new file mode 100644 index 00000000000..788db7fd2ca --- /dev/null +++ b/tests/components/plex/fixtures/release_nochange.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/components/plex/test_update.py b/tests/components/plex/test_update.py new file mode 100644 index 00000000000..ce50f67a0d9 --- /dev/null +++ b/tests/components/plex/test_update.py @@ -0,0 +1,111 @@ +"""Tests for update entities.""" +import pytest +import requests_mock + +from homeassistant.components.update import ( + DOMAIN as UPDATE_DOMAIN, + SCAN_INTERVAL as UPDATER_SCAN_INTERVAL, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, HomeAssistantError +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator + +UPDATE_ENTITY = "update.plex_media_server_plex_server_1" + + +async def test_plex_update( + hass: HomeAssistant, + entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + mock_plex_server, + requests_mock: requests_mock.Mocker, + empty_payload: str, + update_check_new: str, + update_check_new_not_updatable: str, +) -> None: + """Test Plex update entity.""" + ws_client = await hass_ws_client(hass) + + assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": UPDATE_ENTITY, + } + ) + result = await ws_client.receive_json() + assert result["result"] is None + + apply_mock = requests_mock.put("/updater/apply") + + # Failed updates + requests_mock.get("/updater/status", status_code=500) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL) + await hass.async_block_till_done() + + requests_mock.get("/updater/status", text=empty_payload) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL) + await hass.async_block_till_done() + + # New release (not updatable) + requests_mock.get("/updater/status", text=update_check_new_not_updatable) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(UPDATE_ENTITY).state == STATE_ON + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + assert not apply_mock.called + + # New release (updatable) + requests_mock.get("/updater/status", text=update_check_new) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(UPDATE_ENTITY).state == STATE_ON + + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": UPDATE_ENTITY, + } + ) + result = await ws_client.receive_json() + assert result["result"] == "* Summary of\n* release notes" + + # Successful upgrade request + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + assert apply_mock.called_once + + # Failed upgrade request + requests_mock.put("/updater/apply", status_code=500) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index 4dda9af3b54..bc1bc9c8c0c 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -4,11 +4,9 @@ "active_preset": "no_frost", "available": true, "available_schedules": ["None"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": null, "location": "06aecb3d00354375924f50c47af36bd2", "mode": "heat", "model": "Lisa", @@ -101,11 +99,9 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": null, "location": "d27aede973b54be484f6842d1b2802ad", "mode": "heat", "model": "Lisa", @@ -159,11 +155,9 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": null, "location": "d58fec52899f4f1c92e4f8fad6d8c48c", "mode": "heat", "model": "Lisa", @@ -271,11 +265,9 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], - "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", - "last_used": null, "location": "13228dab8ce04617af318a2888b3c548", "mode": "heat", "model": "Jip", diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 0cc28731ff4..6e6da1aa272 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -117,7 +117,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": "CV Jessie", "location": "82fa13f017d240daa0d0ea1775420f24", "mode": "auto", "model": "Lisa", @@ -257,7 +256,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", "hardware": "255", - "last_used": "GF7 Woonkamer", "location": "c50f167537524366a5af7aa3942feb1e", "mode": "auto", "model": "Lisa", @@ -341,7 +339,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer Schema", "location": "12493538af164a409c6a1c79e38afe1c", "mode": "heat", "model": "Lisa", @@ -381,7 +378,6 @@ "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", - "last_used": "Badkamer Schema", "location": "446ac08dd04d4eff8ac57489757b7314", "mode": "heat", "model": "Tom/Floor", @@ -423,7 +419,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer Schema", "location": "08963fec7c53423ca5680aa4cb502c63", "mode": "auto", "model": "Lisa", diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index cdddfdb3439..e7e13e17357 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -62,7 +62,6 @@ "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", - "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", "mode": "auto", "model": "ThermoTouch", diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index ac7e602821e..126852e945d 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -53,9 +53,7 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], - "control_state": "cooling", "dev_class": "thermostat", - "last_used": "Weekschema", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat_cool", "model": "ThermoTouch", @@ -88,7 +86,6 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Adam", - "regulation_mode": "cooling", "regulation_modes": [ "heating", "off", @@ -96,7 +93,7 @@ "bleeding_hot", "cooling" ], - "select_regulation_mode": "heating", + "select_regulation_mode": "cooling", "sensors": { "outdoor_temperature": 29.65 }, @@ -107,11 +104,9 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer", "location": "f871b8c4d63549319221e294e4f88074", "mode": "auto", "model": "Lisa", diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index a4923b1c549..e8a72c9b3fb 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -58,9 +58,7 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], - "control_state": "heating", "dev_class": "thermostat", - "last_used": "Weekschema", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", "model": "ThermoTouch", @@ -91,7 +89,6 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Adam", - "regulation_mode": "heating", "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], "select_regulation_mode": "heating", "sensors": { @@ -104,11 +101,9 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer", "location": "f871b8c4d63549319221e294e4f88074", "mode": "auto", "model": "Lisa", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index f98f253e938..40364e620c3 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -63,7 +63,6 @@ "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", - "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", "mode": "auto", "model": "ThermoTouch", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 56d26f67acb..3a84a59deea 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -63,7 +63,6 @@ "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", - "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", "mode": "auto", "model": "ThermoTouch", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index da6e8964421..597b9710ec5 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -119,7 +119,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', - 'last_used': 'CV Jessie', 'location': '82fa13f017d240daa0d0ea1775420f24', 'mode': 'auto', 'model': 'Lisa', @@ -265,7 +264,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', 'hardware': '255', - 'last_used': 'GF7 Woonkamer', 'location': 'c50f167537524366a5af7aa3942feb1e', 'mode': 'auto', 'model': 'Lisa', @@ -355,7 +353,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', - 'last_used': 'Badkamer Schema', 'location': '12493538af164a409c6a1c79e38afe1c', 'mode': 'heat', 'model': 'Lisa', @@ -401,7 +398,6 @@ 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', - 'last_used': 'Badkamer Schema', 'location': '446ac08dd04d4eff8ac57489757b7314', 'mode': 'heat', 'model': 'Tom/Floor', @@ -449,7 +445,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', - 'last_used': 'Badkamer Schema', 'location': '08963fec7c53423ca5680aa4cb502c63', 'mode': 'auto', 'model': 'Lisa', diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index c73bd5b6190..d8ce2785f2a 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -1,14 +1,17 @@ """Tests for the Plugwise Climate integration.""" -from unittest.mock import MagicMock + +from datetime import timedelta +from unittest.mock import MagicMock, patch from plugwise.exceptions import PlugwiseError import pytest -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate.const import HVACMode from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_adam_climate_entity_attributes( @@ -64,7 +67,7 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.lisa_badkamer") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "idle" + assert state.attributes["hvac_action"] == "heating" assert state.attributes["hvac_modes"] == [HVACMode.HEAT, HVACMode.AUTO] @@ -87,8 +90,6 @@ async def test_adam_climate_adjust_negative_testing( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: """Test exceptions of climate entities.""" - mock_smile_adam.set_preset.side_effect = PlugwiseError - mock_smile_adam.set_schedule_state.side_effect = PlugwiseError mock_smile_adam.set_temperature.side_effect = PlugwiseError with pytest.raises(HomeAssistantError): @@ -99,25 +100,6 @@ async def test_adam_climate_adjust_negative_testing( blocking=True, ) - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "climate", - "set_preset_mode", - {"entity_id": "climate.zone_thermostat_jessie", "preset_mode": "home"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "climate", - "set_hvac_mode", - { - "entity_id": "climate.zone_thermostat_jessie", - "hvac_mode": HVACMode.AUTO, - }, - blocking=True, - ) - async def test_adam_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry @@ -129,7 +111,6 @@ async def test_adam_climate_entity_climate_changes( {"entity_id": "climate.zone_lisa_wk", "temperature": 25}, blocking=True, ) - assert mock_smile_adam.set_temperature.call_count == 1 mock_smile_adam.set_temperature.assert_called_with( "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} @@ -145,7 +126,6 @@ async def test_adam_climate_entity_climate_changes( }, blocking=True, ) - assert mock_smile_adam.set_temperature.call_count == 2 mock_smile_adam.set_temperature.assert_called_with( "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} @@ -165,7 +145,6 @@ async def test_adam_climate_entity_climate_changes( {"entity_id": "climate.zone_lisa_wk", "preset_mode": "away"}, blocking=True, ) - assert mock_smile_adam.set_preset.call_count == 1 mock_smile_adam.set_preset.assert_called_with( "c50f167537524366a5af7aa3942feb1e", "away" @@ -173,26 +152,13 @@ async def test_adam_climate_entity_climate_changes( await hass.services.async_call( "climate", - "set_temperature", - {"entity_id": "climate.zone_thermostat_jessie", "temperature": 25}, + "set_hvac_mode", + {"entity_id": "climate.zone_lisa_wk", "hvac_mode": "heat"}, blocking=True, ) - - assert mock_smile_adam.set_temperature.call_count == 3 - mock_smile_adam.set_temperature.assert_called_with( - "82fa13f017d240daa0d0ea1775420f24", {"setpoint": 25.0} - ) - - await hass.services.async_call( - "climate", - "set_preset_mode", - {"entity_id": "climate.zone_thermostat_jessie", "preset_mode": "home"}, - blocking=True, - ) - - assert mock_smile_adam.set_preset.call_count == 2 - mock_smile_adam.set_preset.assert_called_with( - "82fa13f017d240daa0d0ea1775420f24", "home" + assert mock_smile_adam.set_schedule_state.call_count == 2 + mock_smile_adam.set_schedule_state.assert_called_with( + "c50f167537524366a5af7aa3942feb1e", "off" ) with pytest.raises(HomeAssistantError): @@ -270,7 +236,9 @@ async def test_anna_3_climate_entity_attributes( async def test_anna_climate_entity_climate_changes( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_anna: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test handling of user requests in anna climate device environment.""" await hass.services.async_call( @@ -279,7 +247,6 @@ async def test_anna_climate_entity_climate_changes( {"entity_id": "climate.anna", "target_temp_high": 25, "target_temp_low": 20}, blocking=True, ) - assert mock_smile_anna.set_temperature.call_count == 1 mock_smile_anna.set_temperature.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", @@ -292,7 +259,6 @@ async def test_anna_climate_entity_climate_changes( {"entity_id": "climate.anna", "preset_mode": "away"}, blocking=True, ) - assert mock_smile_anna.set_preset.call_count == 1 mock_smile_anna.set_preset.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", "away" @@ -301,24 +267,32 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "heat"}, + {"entity_id": "climate.anna", "hvac_mode": "auto"}, blocking=True, ) - - assert mock_smile_anna.set_temperature.call_count == 1 assert mock_smile_anna.set_schedule_state.call_count == 1 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "off" + "c784ee9fdab44e1395b8dee7d7a497d5", "on" ) await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "auto"}, + {"entity_id": "climate.anna", "hvac_mode": "heat"}, blocking=True, ) - assert mock_smile_anna.set_schedule_state.call_count == 2 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "on" + "c784ee9fdab44e1395b8dee7d7a497d5", "off" ) + data = mock_smile_anna.async_update.return_value + data.devices["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = ["None"] + with patch( + "homeassistant.components.plugwise.coordinator.Smile.async_update", + return_value=data, + ): + async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + state = hass.states.get("climate.anna") + assert state.state == HVACMode.HEAT + assert state.attributes["hvac_modes"] == [HVACMode.HEAT] diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 34f87b5c261..cd96d0d7b81 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -13,13 +13,22 @@ async def test_proximities(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"devices": ["device_tracker.test1"], "tolerance": "1"}, + "home_test2": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1", "device_tracker.test2"], + "tolerance": "1", + }, + "work": { + "devices": ["device_tracker.test1"], + "tolerance": "1", + "zone": "work", + }, } } assert await async_setup_component(hass, DOMAIN, config) - proximities = ["home", "work"] + proximities = ["home", "home_test2", "work"] for prox in proximities: state = hass.states.get(f"proximity.{prox}") @@ -42,7 +51,7 @@ async def test_proximities_setup(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"tolerance": "1"}, + "work": {"tolerance": "1", "zone": "work"}, } } diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py index b7244ccef8d..4131b9142e2 100644 --- a/tests/components/qbittorrent/test_config_flow.py +++ b/tests/components/qbittorrent/test_config_flow.py @@ -4,7 +4,7 @@ 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.config_entries import SOURCE_USER from homeassistant.const import ( CONF_PASSWORD, CONF_SOURCE, @@ -104,33 +104,3 @@ async def test_flow_user_already_configured(hass: HomeAssistant) -> None: ) 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/radarr/__init__.py b/tests/components/radarr/__init__.py index 069eeabe8d8..f7bdf232c9e 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -76,6 +76,11 @@ def mock_connection( headers={"Content-Type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"{url}/api/v3/queue", + text=load_fixture("radarr/queue.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) root_folder_fixture = "rootfolder-linux" if windows: @@ -90,13 +95,9 @@ def mock_connection( headers={"Content-Type": CONTENT_TYPE_JSON}, ) - movie_fixture = "movie" - if single_return: - movie_fixture = f"single-{movie_fixture}" - aioclient_mock.get( f"{url}/api/v3/movie", - text=load_fixture(f"radarr/{movie_fixture}.json"), + text=load_fixture("radarr/movie.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) @@ -114,10 +115,11 @@ def mock_connection_invalid_auth( url: str = URL, ) -> None: """Mock radarr invalid auth errors.""" - aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/command", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/movie", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -125,14 +127,15 @@ def mock_connection_server_error( url: str = URL, ) -> None: """Mock radarr server errors.""" - aioclient_mock.get( - f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR - ) aioclient_mock.get(f"{url}/api/v3/command", status=HTTPStatus.INTERNAL_SERVER_ERROR) aioclient_mock.get(f"{url}/api/v3/movie", status=HTTPStatus.INTERNAL_SERVER_ERROR) + aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.INTERNAL_SERVER_ERROR) aioclient_mock.get( f"{url}/api/v3/rootfolder", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -185,11 +188,6 @@ def patch_async_setup_entry(return_value=True): ) -def patch_radarr(): - """Patch radarr api.""" - return patch("homeassistant.components.radarr.RadarrClient.async_get_system_status") - - def create_entry(hass: HomeAssistant) -> MockConfigEntry: """Create Radarr entry in Home Assistant.""" entry = MockConfigEntry( diff --git a/tests/components/radarr/fixtures/queue.json b/tests/components/radarr/fixtures/queue.json new file mode 100644 index 00000000000..804f1fd3a21 --- /dev/null +++ b/tests/components/radarr/fixtures/queue.json @@ -0,0 +1,153 @@ +{ + "page": 1, + "pageSize": 10, + "sortKey": "timeleft", + "sortDirection": "ascending", + "totalRecords": 2, + "records": [ + { + "movieId": 0, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": true + } + }, + "customFormats": [ + { + "id": 0, + "name": "string", + "includeCustomFormatWhenRenaming": true, + "specifications": [ + { + "name": "string", + "implementation": "string", + "implementationName": "string", + "infoLink": "string", + "negate": true, + "required": true, + "fields": [ + { + "order": 0, + "name": "string", + "label": "string", + "helpText": "string", + "value": "string", + "type": "string", + "advanced": true + } + ] + } + ] + } + ], + "size": 0, + "title": "test", + "sizeleft": 0, + "timeleft": "string", + "estimatedCompletionTime": "2020-01-21T00:01:59Z", + "status": "string", + "trackedDownloadStatus": "string", + "trackedDownloadState": "downloading", + "statusMessages": [ + { + "title": "string", + "messages": ["string"] + } + ], + "errorMessage": "string", + "downloadId": "string", + "protocol": "unknown", + "downloadClient": "string", + "indexer": "string", + "outputPath": "string", + "id": 0 + }, + { + "movieId": 0, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": true + } + }, + "customFormats": [ + { + "id": 0, + "name": "string", + "includeCustomFormatWhenRenaming": true, + "specifications": [ + { + "name": "string", + "implementation": "string", + "implementationName": "string", + "infoLink": "string", + "negate": true, + "required": true, + "fields": [ + { + "order": 0, + "name": "string", + "label": "string", + "helpText": "string", + "value": "string", + "type": "string", + "advanced": true + } + ] + } + ] + } + ], + "size": 0, + "title": "test2", + "sizeleft": 1000000, + "timeleft": "00:00:00", + "estimatedCompletionTime": "2020-01-21T00:01:59Z", + "status": "string", + "trackedDownloadStatus": "string", + "trackedDownloadState": "downloading", + "statusMessages": [ + { + "title": "string", + "messages": ["string"] + } + ], + "errorMessage": "string", + "downloadId": "string", + "protocol": "unknown", + "downloadClient": "string", + "indexer": "string", + "outputPath": "string", + "id": 0 + } + ] +} diff --git a/tests/components/radarr/fixtures/single-movie.json b/tests/components/radarr/fixtures/single-movie.json deleted file mode 100644 index db9e720d285..00000000000 --- a/tests/components/radarr/fixtures/single-movie.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "id": 0, - "title": "string", - "originalTitle": "string", - "alternateTitles": [ - { - "sourceType": "tmdb", - "movieId": 1, - "title": "string", - "sourceId": 0, - "votes": 0, - "voteCount": 0, - "language": { - "id": 1, - "name": "English" - }, - "id": 1 - } - ], - "sortTitle": "string", - "sizeOnDisk": 0, - "overview": "string", - "inCinemas": "2020-11-06T00:00:00Z", - "physicalRelease": "2019-03-19T00:00:00Z", - "images": [ - { - "coverType": "poster", - "url": "string", - "remoteUrl": "string" - } - ], - "website": "string", - "year": 0, - "hasFile": true, - "youTubeTrailerId": "string", - "studio": "string", - "path": "string", - "rootFolderPath": "string", - "qualityProfileId": 0, - "monitored": true, - "minimumAvailability": "announced", - "isAvailable": true, - "folderName": "string", - "runtime": 0, - "cleanTitle": "string", - "imdbId": "string", - "tmdbId": 0, - "titleSlug": "string", - "certification": "string", - "genres": ["string"], - "tags": [0], - "added": "2018-12-28T05:56:49Z", - "ratings": { - "votes": 0, - "value": 0 - }, - "movieFile": { - "movieId": 0, - "relativePath": "string", - "path": "string", - "size": 916662234, - "dateAdded": "2020-11-26T02:00:35Z", - "indexerFlags": 1, - "quality": { - "quality": { - "id": 14, - "name": "WEBRip-720p", - "source": "webrip", - "resolution": 720, - "modifier": "none" - }, - "revision": { - "version": 1, - "real": 0, - "isRepack": false - } - }, - "mediaInfo": { - "audioBitrate": 0, - "audioChannels": 2, - "audioCodec": "AAC", - "audioLanguages": "", - "audioStreamCount": 1, - "videoBitDepth": 8, - "videoBitrate": 1000000, - "videoCodec": "x264", - "videoFps": 25.0, - "resolution": "1280x534", - "runTime": "1:49:06", - "scanType": "Progressive", - "subtitles": "" - }, - "originalFilePath": "string", - "qualityCutoffNotMet": true, - "languages": [ - { - "id": 26, - "name": "Hindi" - } - ], - "edition": "", - "id": 35361 - }, - "collection": { - "name": "string", - "tmdbId": 0, - "images": [ - { - "coverType": "poster", - "url": "string", - "remoteUrl": "string" - } - ] - }, - "status": "deleted" -} diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index 6b602c8c4d1..f16e5895633 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,12 +1,10 @@ """Test Radarr integration.""" -from aiopyarr import exceptions - from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import create_entry, patch_radarr, setup_integration +from . import create_entry, mock_connection_invalid_auth, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -33,15 +31,16 @@ async def test_async_setup_entry_not_ready( assert not hass.data.get(DOMAIN) -async def test_async_setup_entry_auth_failed(hass: HomeAssistant) -> None: +async def test_async_setup_entry_auth_failed( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Test that it throws ConfigEntryAuthFailed when authentication fails.""" entry = create_entry(hass) - with patch_radarr() as radarrmock: - radarrmock.side_effect = exceptions.ArrAuthenticationException - await hass.config_entries.async_setup(entry.entry_id) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state == ConfigEntryState.SETUP_ERROR - assert not hass.data.get(DOMAIN) + mock_connection_invalid_auth(aioclient_mock) + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) async def test_device_info( diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index f4f863d9bb6..90ab683037b 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -1,7 +1,11 @@ """The tests for Radarr sensor platform.""" import pytest -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -55,3 +59,17 @@ async def test_sensors( state = hass.states.get("sensor.mock_title_start_time") assert state.state == "2020-09-01T23:50:20+00:00" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.mock_title_queue") + assert state.state == "2" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + + +async def test_windows( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test for successfully setting up the Radarr platform on Windows.""" + await setup_integration(hass, aioclient_mock, windows=True) + + state = hass.states.get("sensor.mock_title_disk_space_tv") + assert state.state == "263.10" diff --git a/tests/components/random/test_config_flow.py b/tests/components/random/test_config_flow.py new file mode 100644 index 00000000000..909e866ea92 --- /dev/null +++ b/tests/components/random/test_config_flow.py @@ -0,0 +1,201 @@ +"""Test the Random config flow.""" +from typing import Any +from unittest.mock import patch + +import pytest +from voluptuous import Invalid + +from homeassistant import config_entries +from homeassistant.components.random import async_setup_entry +from homeassistant.components.random.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ( + "entity_type", + "extra_input", + "extra_options", + ), + ( + ( + "binary_sensor", + {}, + {}, + ), + ( + "sensor", + { + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + }, + { + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + "minimum": 0, + "maximum": 20, + }, + ), + ( + "sensor", + {}, + {"minimum": 0, "maximum": 20}, + ), + ), +) +async def test_config_flow( + hass: HomeAssistant, + entity_type: str, + extra_input: dict[str, Any], + extra_options: dict[str, Any], +) -> None: + """Test the config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": entity_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == entity_type + + with patch( + "homeassistant.components.random.async_setup_entry", wraps=async_setup_entry + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My random entity", + **extra_input, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My random entity" + assert result["data"] == {} + assert result["options"] == { + "name": "My random entity", + "entity_type": entity_type, + **extra_options, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("device_class", "unit_of_measurement"), + [ + (SensorDeviceClass.POWER, UnitOfEnergy.WATT_HOUR), + (SensorDeviceClass.ILLUMINANCE, UnitOfEnergy.WATT_HOUR), + ], +) +async def test_wrong_uom( + hass: HomeAssistant, device_class: SensorDeviceClass, unit_of_measurement: str +) -> None: + """Test entering a wrong unit of measurement.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "sensor" + + with pytest.raises(Invalid, match="is not a valid unit for device class"): + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My random entity", + "device_class": device_class, + "unit_of_measurement": unit_of_measurement, + }, + ) + + +@pytest.mark.parametrize( + ( + "entity_type", + "extra_options", + "options_options", + ), + ( + ( + "sensor", + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.WATT_HOUR, + "minimum": 0, + "maximum": 20, + }, + { + "minimum": 10, + "maximum": 20, + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + }, + ), + ), +) +async def test_options( + hass: HomeAssistant, + entity_type: str, + extra_options, + options_options, +) -> None: + """Test reconfiguring.""" + + random_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My random", + "entity_type": entity_type, + **extra_options, + }, + title="My random", + ) + random_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(random_config_entry.entry_id) + await hass.async_block_till_done() + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == entity_type + assert "name" not in result["data_schema"].schema + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=options_options, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "name": "My random", + "entity_type": entity_type, + **options_options, + } + assert config_entry.data == {} + assert config_entry.options == { + "name": "My random", + "entity_type": entity_type, + **options_options, + } + assert config_entry.title == "My random" diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 521be81c89b..a982eeb39be 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -11,7 +11,7 @@ import importlib import sys import time from typing import Any, Literal, cast -from unittest.mock import patch, sentinel +from unittest.mock import MagicMock, patch, sentinel from freezegun import freeze_time from sqlalchemy import create_engine @@ -430,3 +430,16 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: ), ): 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) diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index e007d2408dd..852419559b2 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -1,22 +1,26 @@ """The tests for the recorder filter matching the EntityFilter component.""" +import datetime import importlib import sys +from typing import Any from unittest.mock import patch import uuid from freezegun import freeze_time import pytest from sqlalchemy import create_engine, inspect +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from homeassistant.components import recorder -from homeassistant.components.recorder import core, migration, statistics +from homeassistant.components.recorder import core, db_schema, migration, statistics from homeassistant.components.recorder.db_schema import ( Events, EventTypes, States, StatesMeta, ) +from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.tasks import ( EntityIDMigrationTask, @@ -30,7 +34,11 @@ from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from homeassistant.util.ulid import bytes_to_ulid, ulid_at_time, ulid_to_bytes -from .common import async_recorder_block_till_done, async_wait_recording_done +from .common import ( + async_attach_db_engine, + async_recorder_block_till_done, + async_wait_recording_done, +) from tests.typing import RecorderInstanceGenerator @@ -844,3 +852,578 @@ async def test_migrate_null_event_type_ids( 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 + + +async def test_stats_timestamp_conversion_is_reentrant( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test stats migration is reentrant.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + await async_attach_db_engine(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] + now = dt_util.utcnow() + one_year_ago = now - datetime.timedelta(days=365) + six_months_ago = now - datetime.timedelta(days=180) + one_month_ago = now - datetime.timedelta(days=30) + + def _do_migration(): + migration._migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, instance.get_session, instance.engine + ) + + def _insert_fake_metadata(): + with session_scope(hass=hass) as session: + session.add( + old_db_schema.StatisticsMeta( + id=1000, + statistic_id="test", + source="test", + unit_of_measurement="test", + has_mean=True, + has_sum=True, + name="1", + ) + ) + + def _insert_pre_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add( + old_db_schema.StatisticsShortTerm( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ) + ) + + def _insert_post_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add( + db_schema.StatisticsShortTerm( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ) + ) + + def _get_all_short_term_stats() -> list[dict[str, Any]]: + with session_scope(hass=hass) as session: + results = [] + for result in ( + session.query(old_db_schema.StatisticsShortTerm) + .where(old_db_schema.StatisticsShortTerm.metadata_id == 1000) + .all() + ): + results.append( + { + field.name: getattr(result, field.name) + for field in old_db_schema.StatisticsShortTerm.__table__.c + } + ) + return sorted(results, key=lambda row: row["start_ts"]) + + # Do not optimize this block, its intentionally written to interleave + # with the migration + await hass.async_add_executor_job(_insert_fake_metadata) + await async_wait_recording_done(hass) + await hass.async_add_executor_job(_insert_pre_timestamp_stat, one_year_ago) + await async_wait_recording_done(hass) + await hass.async_add_executor_job(_do_migration) + await hass.async_add_executor_job(_insert_post_timestamp_stat, six_months_ago) + await async_wait_recording_done(hass) + await hass.async_add_executor_job(_do_migration) + await hass.async_add_executor_job(_insert_pre_timestamp_stat, one_month_ago) + await async_wait_recording_done(hass) + await hass.async_add_executor_job(_do_migration) + + final_result = await hass.async_add_executor_job(_get_all_short_term_stats) + # Normalize timestamps since each engine returns them differently + for row in final_result: + if row["created"] is not None: + row["created"] = process_timestamp(row["created"]).replace(tzinfo=None) + if row["start"] is not None: + row["start"] = process_timestamp(row["start"]).replace(tzinfo=None) + if row["last_reset"] is not None: + row["last_reset"] = process_timestamp(row["last_reset"]).replace( + tzinfo=None + ) + + assert final_result == [ + { + "created": process_timestamp(one_year_ago).replace(tzinfo=None), + "created_ts": one_year_ago.timestamp(), + "id": 1, + "last_reset": process_timestamp(one_year_ago).replace(tzinfo=None), + "last_reset_ts": one_year_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": process_timestamp(one_year_ago).replace(tzinfo=None), + "start_ts": one_year_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": process_timestamp(one_month_ago).replace(tzinfo=None), + "created_ts": one_month_ago.timestamp(), + "id": 3, + "last_reset": process_timestamp(one_month_ago).replace(tzinfo=None), + "last_reset_ts": one_month_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": process_timestamp(one_month_ago).replace(tzinfo=None), + "start_ts": one_month_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] + + +async def test_stats_timestamp_with_one_by_one( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test stats migration with one by one.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + await async_attach_db_engine(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] + now = dt_util.utcnow() + one_year_ago = now - datetime.timedelta(days=365) + six_months_ago = now - datetime.timedelta(days=180) + one_month_ago = now - datetime.timedelta(days=30) + + def _do_migration(): + with patch.object( + migration, + "_migrate_statistics_columns_to_timestamp", + side_effect=IntegrityError("test", "test", "test"), + ): + migration._migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, instance.get_session, instance.engine + ) + + def _insert_fake_metadata(): + with session_scope(hass=hass) as session: + session.add( + old_db_schema.StatisticsMeta( + id=1000, + statistic_id="test", + source="test", + unit_of_measurement="test", + has_mean=True, + has_sum=True, + name="1", + ) + ) + + def _insert_pre_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add_all( + ( + old_db_schema.StatisticsShortTerm( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ), + old_db_schema.Statistics( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ), + ) + ) + + def _insert_post_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add_all( + ( + db_schema.StatisticsShortTerm( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ), + db_schema.Statistics( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ), + ) + ) + + def _get_all_stats(table: old_db_schema.StatisticsBase) -> list[dict[str, Any]]: + """Get all stats from a table.""" + with session_scope(hass=hass) as session: + results = [] + for result in session.query(table).where(table.metadata_id == 1000).all(): + results.append( + { + field.name: getattr(result, field.name) + for field in table.__table__.c + } + ) + return sorted(results, key=lambda row: row["start_ts"]) + + def _insert_and_do_migration(): + _insert_fake_metadata() + _insert_pre_timestamp_stat(one_year_ago) + _insert_post_timestamp_stat(six_months_ago) + _insert_pre_timestamp_stat(one_month_ago) + _do_migration() + + await hass.async_add_executor_job(_insert_and_do_migration) + final_short_term_result = await hass.async_add_executor_job( + _get_all_stats, old_db_schema.StatisticsShortTerm + ) + final_short_term_result = sorted( + final_short_term_result, key=lambda row: row["start_ts"] + ) + + assert final_short_term_result == [ + { + "created": None, + "created_ts": one_year_ago.timestamp(), + "id": 1, + "last_reset": None, + "last_reset_ts": one_year_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_year_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": one_month_ago.timestamp(), + "id": 3, + "last_reset": None, + "last_reset_ts": one_month_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_month_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] + + final_result = await hass.async_add_executor_job( + _get_all_stats, old_db_schema.Statistics + ) + final_result = sorted(final_result, key=lambda row: row["start_ts"]) + + assert final_result == [ + { + "created": None, + "created_ts": one_year_ago.timestamp(), + "id": 1, + "last_reset": None, + "last_reset_ts": one_year_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_year_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": one_month_ago.timestamp(), + "id": 3, + "last_reset": None, + "last_reset_ts": one_month_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_month_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] + + +async def test_stats_timestamp_with_one_by_one_removes_duplicates( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test stats migration with one by one removes duplicates.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + await async_attach_db_engine(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] + now = dt_util.utcnow() + one_year_ago = now - datetime.timedelta(days=365) + six_months_ago = now - datetime.timedelta(days=180) + one_month_ago = now - datetime.timedelta(days=30) + + def _do_migration(): + with patch.object( + migration, + "_migrate_statistics_columns_to_timestamp", + side_effect=IntegrityError("test", "test", "test"), + ), patch.object( + migration, + "migrate_single_statistics_row_to_timestamp", + side_effect=IntegrityError("test", "test", "test"), + ): + migration._migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, instance.get_session, instance.engine + ) + + def _insert_fake_metadata(): + with session_scope(hass=hass) as session: + session.add( + old_db_schema.StatisticsMeta( + id=1000, + statistic_id="test", + source="test", + unit_of_measurement="test", + has_mean=True, + has_sum=True, + name="1", + ) + ) + + def _insert_pre_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add_all( + ( + old_db_schema.StatisticsShortTerm( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ), + old_db_schema.Statistics( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ), + ) + ) + + def _insert_post_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add_all( + ( + db_schema.StatisticsShortTerm( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ), + db_schema.Statistics( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ), + ) + ) + + def _get_all_stats(table: old_db_schema.StatisticsBase) -> list[dict[str, Any]]: + """Get all stats from a table.""" + with session_scope(hass=hass) as session: + results = [] + for result in session.query(table).where(table.metadata_id == 1000).all(): + results.append( + { + field.name: getattr(result, field.name) + for field in table.__table__.c + } + ) + return sorted(results, key=lambda row: row["start_ts"]) + + def _insert_and_do_migration(): + _insert_fake_metadata() + _insert_pre_timestamp_stat(one_year_ago) + _insert_post_timestamp_stat(six_months_ago) + _insert_pre_timestamp_stat(one_month_ago) + _do_migration() + + await hass.async_add_executor_job(_insert_and_do_migration) + final_short_term_result = await hass.async_add_executor_job( + _get_all_stats, old_db_schema.StatisticsShortTerm + ) + final_short_term_result = sorted( + final_short_term_result, key=lambda row: row["start_ts"] + ) + + assert final_short_term_result == [ + { + "created": None, + "created_ts": one_year_ago.timestamp(), + "id": 1, + "last_reset": None, + "last_reset_ts": one_year_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_year_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": one_month_ago.timestamp(), + "id": 3, + "last_reset": None, + "last_reset_ts": one_month_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_month_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] + + # All the duplicates should have been removed but + # the non-duplicates should still be there + final_result = await hass.async_add_executor_job( + _get_all_stats, old_db_schema.Statistics + ) + assert final_result == [ + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 096108e0349..4faa8dc7e8a 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -10,11 +10,7 @@ from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session from homeassistant.components import recorder -from homeassistant.components.recorder import purge, queries -from homeassistant.components.recorder.const import ( - SQLITE_MAX_BIND_VARS, - SupportedDialect, -) +from homeassistant.components.recorder.const import SupportedDialect from homeassistant.components.recorder.db_schema import ( Events, EventTypes, @@ -71,6 +67,39 @@ def mock_use_sqlite(request): yield +async def test_purge_big_database( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting 2/3 old states from a big database.""" + + instance = await async_setup_recorder_instance(hass) + + for _ in range(12): + await _add_test_states(hass, wait_recording_done=False) + await async_wait_recording_done(hass) + + with patch.object(instance, "max_bind_vars", 72), patch.object( + instance.database_engine, "max_bind_vars", 72 + ), session_scope(hass=hass) as session: + states = session.query(States) + state_attributes = session.query(StateAttributes) + assert states.count() == 72 + assert state_attributes.count() == 3 + + purge_before = dt_util.utcnow() - timedelta(days=4) + + finished = purge_old_data( + instance, + purge_before, + states_batch_size=1, + events_batch_size=1, + repack=False, + ) + assert not finished + assert states.count() == 24 + assert state_attributes.count() == 1 + + async def test_purge_old_states( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: @@ -628,7 +657,7 @@ async def test_purge_cutoff_date( service_data = {"keep_days": 2} # Force multiple purge batches to be run - rows = SQLITE_MAX_BIND_VARS + 1 + rows = 999 cutoff = dt_util.utcnow() - timedelta(days=service_data["keep_days"]) await _add_db_entries(hass, cutoff, rows) @@ -1411,7 +1440,7 @@ async def test_purge_entities( assert states.count() == 0 -async def _add_test_states(hass: HomeAssistant): +async def _add_test_states(hass: HomeAssistant, wait_recording_done: bool = True): """Add multiple states to the db for testing.""" utcnow = dt_util.utcnow() five_days_ago = utcnow - timedelta(days=5) @@ -1421,24 +1450,26 @@ async def _add_test_states(hass: HomeAssistant): 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) + if wait_recording_done: + 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 freeze_time() as freezer: + 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 freeze_time(timestamp): + freezer.move_to(timestamp) await set_state("test.recorder2", state, attributes=attributes) @@ -1453,18 +1484,19 @@ async def _add_test_events(hass: HomeAssistant, iterations: int = 1): # thread as well can cause the test to fail await async_wait_recording_done(hass) - for _ in range(iterations): - for event_id in range(6): - if event_id < 2: - 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): + with freeze_time() as freezer: + 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" + freezer.move_to(timestamp) hass.bus.async_fire(event_type, event_data) await async_wait_recording_done(hass) @@ -1605,11 +1637,11 @@ async def test_purge_many_old_events( ) -> None: """Test deleting old events.""" 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) + instance = await async_setup_recorder_instance(hass) + with patch.object(instance, "max_bind_vars", old_events_count), patch.object( + instance.database_engine, "max_bind_vars", old_events_count + ): await _add_test_events(hass, old_events_count) with session_scope(hass=hass) as session: diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 3b315481f4e..f386fd19e36 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta import json import sqlite3 -from unittest.mock import MagicMock, patch +from unittest.mock import patch from freezegun import freeze_time import pytest @@ -13,10 +13,7 @@ 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.const import 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 ( @@ -30,6 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from .common import ( + async_attach_db_engine, async_recorder_block_till_done, async_wait_purge_done, async_wait_recording_done, @@ -67,25 +65,12 @@ def mock_use_sqlite(request): 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 async_attach_db_engine(hass) await _add_test_states(hass) @@ -181,7 +166,7 @@ async def test_purge_old_states_encouters_database_corruption( return await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_states(hass) await async_wait_recording_done(hass) @@ -214,7 +199,7 @@ async def test_purge_old_states_encounters_temporary_mysql_error( ) -> None: """Test retry on specific mysql operational errors.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_states(hass) await async_wait_recording_done(hass) @@ -246,7 +231,7 @@ async def test_purge_old_states_encounters_operational_error( ) -> 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 async_attach_db_engine(hass) await _add_test_states(hass) await async_wait_recording_done(hass) @@ -271,7 +256,7 @@ async def test_purge_old_events( ) -> None: """Test deleting old events.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_events(hass) @@ -309,7 +294,7 @@ async def test_purge_old_recorder_runs( ) -> None: """Test deleting old recorder runs keeps current run.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_recorder_runs(hass) @@ -346,7 +331,7 @@ async def test_purge_old_statistics_runs( ) -> None: """Test deleting old statistics runs keeps the latest run.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_statistics_runs(hass) @@ -387,7 +372,7 @@ async def test_purge_method( assert run1.start == run2.start await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) service_data = {"keep_days": 4} await _add_test_events(hass) @@ -525,7 +510,7 @@ async def test_purge_edge_case( ) await async_setup_recorder_instance(hass, None) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await async_wait_purge_done(hass) @@ -624,14 +609,14 @@ async def test_purge_cutoff_date( ) instance = await async_setup_recorder_instance(hass, None) - await _async_attach_db_engine(hass) + 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 + rows = 999 cutoff = dt_util.utcnow() - timedelta(days=service_data["keep_days"]) await _add_db_entries(hass, cutoff, rows) @@ -718,21 +703,22 @@ async def _add_test_states(hass: HomeAssistant): 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 freeze_time() as freezer: + 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 freeze_time(timestamp): + freezer.move_to(timestamp) await set_state("test.recorder2", state, attributes=attributes) @@ -950,48 +936,52 @@ async def test_purge_many_old_events( ) -> None: """Test deleting old events.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) - await _add_test_events(hass, SQLITE_MAX_BIND_VARS) + old_events_count = 5 + with patch.object(instance, "max_bind_vars", old_events_count), patch.object( + instance.database_engine, "max_bind_vars", old_events_count + ): + 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 + with session_scope(hass=hass) as session: + events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + assert events.count() == old_events_count * 6 - purge_before = dt_util.utcnow() - timedelta(days=4) + 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 + # 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() == old_events_count * 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 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 - # 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 + # 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( @@ -999,7 +989,7 @@ async def test_purge_can_mix_legacy_and_new_format( ) -> None: """Test purging with legacy and new events.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await async_wait_recording_done(hass) # New databases are no longer created with the legacy events index @@ -1112,7 +1102,7 @@ async def test_purge_can_mix_legacy_and_new_format_with_detached_state( 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_attach_db_engine(hass) await async_wait_recording_done(hass) # New databases are no longer created with the legacy events index @@ -1252,7 +1242,7 @@ async def test_purge_entities_keep_days( ) -> 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 async_attach_db_engine(hass) await hass.async_block_till_done() await async_wait_recording_done(hass) diff --git a/tests/components/recovery_mode/__init__.py b/tests/components/recovery_mode/__init__.py new file mode 100644 index 00000000000..1f2f2fcadd8 --- /dev/null +++ b/tests/components/recovery_mode/__init__.py @@ -0,0 +1 @@ +"""Tests for the Recovery Mode integration.""" diff --git a/tests/components/safe_mode/test_init.py b/tests/components/recovery_mode/test_init.py similarity index 70% rename from tests/components/safe_mode/test_init.py rename to tests/components/recovery_mode/test_init.py index 82f5f5180da..ec8db443ef1 100644 --- a/tests/components/safe_mode/test_init.py +++ b/tests/components/recovery_mode/test_init.py @@ -1,4 +1,4 @@ -"""Tests for safe mode integration.""" +"""Tests for the Recovery Mode integration.""" from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -6,8 +6,8 @@ from tests.common import async_get_persistent_notifications async def test_works(hass: HomeAssistant) -> None: - """Test safe mode works.""" - assert await async_setup_component(hass, "safe_mode", {}) + """Test Recovery Mode works.""" + assert await async_setup_component(hass, "recovery_mode", {}) await hass.async_block_till_done() notifications = async_get_persistent_notifications(hass) assert len(notifications) == 1 diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 97ff2fd58a0..76e6075a18f 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -110,12 +110,21 @@ async def test_get_actions_hidden_auxiliary( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -126,7 +135,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -135,7 +144,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -144,7 +153,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -176,12 +185,21 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -192,7 +210,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index b07747771d9..1048aa1b081 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -179,12 +179,21 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -199,7 +208,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -218,7 +227,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -253,12 +262,21 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -273,7 +291,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } @@ -301,6 +319,7 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -310,7 +329,15 @@ async def test_if_fires_on_for_condition( point2 = point1 + timedelta(seconds=10) point3 = point2 + timedelta(seconds=10) - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -325,7 +352,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index b5dcca3dc4c..711b9672aa0 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -177,12 +177,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -195,7 +204,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -219,7 +228,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -243,7 +252,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -287,12 +296,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -305,7 +323,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -341,12 +359,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -359,7 +386,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index 9625810bedb..fbde0470cac 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -53,6 +54,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', @@ -81,6 +83,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777123_hatch_status', @@ -109,6 +112,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', @@ -137,6 +141,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', @@ -165,6 +170,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777123_driver_door_status', @@ -193,6 +199,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777123_passenger_door_status', @@ -293,6 +300,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -324,6 +332,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_plugged_in', @@ -352,6 +361,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_charging', @@ -380,6 +390,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', @@ -408,6 +419,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777123_hatch_status', @@ -436,6 +448,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', @@ -464,6 +477,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', @@ -492,6 +506,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777123_driver_door_status', @@ -520,6 +535,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777123_passenger_door_status', @@ -642,6 +658,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -673,6 +690,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', @@ -701,6 +719,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', @@ -729,6 +748,7 @@ 'original_icon': 'mdi:fan-off', 'original_name': 'HVAC', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1aaaaa555777999_hvac_status', @@ -796,6 +816,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -827,6 +848,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', @@ -855,6 +877,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', @@ -883,6 +906,7 @@ 'original_icon': 'mdi:fan-off', 'original_name': 'HVAC', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1aaaaa555777999_hvac_status', @@ -911,6 +935,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_lock_status', @@ -939,6 +964,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777999_hatch_status', @@ -967,6 +993,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', @@ -995,6 +1022,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', @@ -1023,6 +1051,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777999_driver_door_status', @@ -1051,6 +1080,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777999_passenger_door_status', @@ -1184,6 +1214,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -1215,6 +1246,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', @@ -1243,6 +1275,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777123_hatch_status', @@ -1271,6 +1304,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', @@ -1299,6 +1333,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', @@ -1327,6 +1362,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777123_driver_door_status', @@ -1355,6 +1391,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777123_passenger_door_status', @@ -1455,6 +1492,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -1486,6 +1524,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_plugged_in', @@ -1514,6 +1553,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_charging', @@ -1542,6 +1582,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', @@ -1570,6 +1611,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777123_hatch_status', @@ -1598,6 +1640,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', @@ -1626,6 +1669,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', @@ -1654,6 +1698,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777123_driver_door_status', @@ -1682,6 +1727,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777123_passenger_door_status', @@ -1804,6 +1850,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -1835,6 +1882,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', @@ -1863,6 +1911,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', @@ -1891,6 +1940,7 @@ 'original_icon': 'mdi:fan-off', 'original_name': 'HVAC', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1aaaaa555777999_hvac_status', @@ -1958,6 +2008,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -1989,6 +2040,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', @@ -2017,6 +2069,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', @@ -2045,6 +2098,7 @@ 'original_icon': 'mdi:fan-off', 'original_name': 'HVAC', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1aaaaa555777999_hvac_status', @@ -2073,6 +2127,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_lock_status', @@ -2101,6 +2156,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777999_hatch_status', @@ -2129,6 +2185,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', @@ -2157,6 +2214,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', @@ -2185,6 +2243,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777999_driver_door_status', @@ -2213,6 +2272,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777999_passenger_door_status', diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index 8c56a3842ea..90715cb56c2 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -53,6 +54,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', @@ -98,6 +100,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -129,6 +132,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', @@ -157,6 +161,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777123_start_charge', @@ -185,6 +190,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777123_stop_charge', @@ -252,6 +258,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -283,6 +290,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', @@ -311,6 +319,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777999_start_charge', @@ -339,6 +348,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777999_stop_charge', @@ -406,6 +416,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -437,6 +448,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', @@ -465,6 +477,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777999_start_charge', @@ -493,6 +506,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777999_stop_charge', @@ -560,6 +574,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -591,6 +606,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', @@ -636,6 +652,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -667,6 +684,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', @@ -695,6 +713,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777123_start_charge', @@ -723,6 +742,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777123_stop_charge', @@ -790,6 +810,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -821,6 +842,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', @@ -849,6 +871,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777999_start_charge', @@ -877,6 +900,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777999_stop_charge', @@ -944,6 +968,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -975,6 +1000,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', @@ -1003,6 +1029,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777999_start_charge', @@ -1031,6 +1058,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777999_stop_charge', diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index 474791791d9..0f901c8ce4c 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -53,6 +54,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777123_location', @@ -99,6 +101,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -130,6 +133,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777123_location', @@ -176,6 +180,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -213,6 +218,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -244,6 +250,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777999_location', @@ -290,6 +297,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -321,6 +329,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777123_location', @@ -370,6 +379,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -401,6 +411,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777123_location', @@ -450,6 +461,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -487,6 +499,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -518,6 +531,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777999_location', diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index c5bbc6b2002..932a302e5f7 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -59,6 +60,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -96,6 +98,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777123_charge_mode', @@ -146,6 +149,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -183,6 +187,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777999_charge_mode', @@ -233,6 +238,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -270,6 +276,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777999_charge_mode', @@ -320,6 +327,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -357,6 +365,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -394,6 +403,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777123_charge_mode', @@ -444,6 +454,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -481,6 +492,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777999_charge_mode', @@ -531,6 +543,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -568,6 +581,7 @@ 'original_icon': 'mdi:calendar-clock', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777999_charge_mode', diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 46b231ac7ef..9fb302a1108 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -55,6 +56,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777123_mileage', @@ -85,6 +87,7 @@ 'original_icon': 'mdi:gas-station', 'original_name': 'Fuel autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', @@ -115,6 +118,7 @@ 'original_icon': 'mdi:fuel', 'original_name': 'Fuel quantity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1aaaaa555777123_fuel_quantity', @@ -143,6 +147,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777123_location_last_activity', @@ -171,6 +176,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777123_res_state', @@ -199,6 +205,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777123_res_state_code', @@ -306,6 +313,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -339,6 +347,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_battery_level', @@ -378,6 +387,7 @@ 'original_icon': 'mdi:flash-off', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777123_charge_state', @@ -408,6 +418,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', @@ -438,6 +449,7 @@ 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1aaaaa555777123_charging_power', @@ -473,6 +485,7 @@ 'original_icon': 'mdi:power-plug-off', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777123_plug_state', @@ -503,6 +516,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777123_battery_autonomy', @@ -533,6 +547,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777123_battery_available_energy', @@ -563,6 +578,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777123_battery_temperature', @@ -591,6 +607,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777123_battery_last_activity', @@ -621,6 +638,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777123_mileage', @@ -651,6 +669,7 @@ 'original_icon': 'mdi:gas-station', 'original_name': 'Fuel autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', @@ -681,6 +700,7 @@ 'original_icon': 'mdi:fuel', 'original_name': 'Fuel quantity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1aaaaa555777123_fuel_quantity', @@ -709,6 +729,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777123_location_last_activity', @@ -737,6 +758,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777123_res_state', @@ -765,6 +787,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777123_res_state_code', @@ -1003,6 +1026,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -1036,6 +1060,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', @@ -1075,6 +1100,7 @@ 'original_icon': 'mdi:flash-off', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777999_charge_state', @@ -1105,6 +1131,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', @@ -1135,6 +1162,7 @@ 'original_icon': None, 'original_name': 'Charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_power', 'unique_id': 'vf1aaaaa555777999_charging_power', @@ -1170,6 +1198,7 @@ 'original_icon': 'mdi:power-plug-off', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777999_plug_state', @@ -1200,6 +1229,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777999_battery_autonomy', @@ -1230,6 +1260,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777999_battery_available_energy', @@ -1260,6 +1291,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777999_battery_temperature', @@ -1288,6 +1320,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777999_battery_last_activity', @@ -1318,6 +1351,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777999_mileage', @@ -1348,6 +1382,7 @@ 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1aaaaa555777999_outside_temperature', @@ -1376,6 +1411,7 @@ 'original_icon': None, 'original_name': 'HVAC SoC threshold', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', @@ -1404,6 +1440,7 @@ 'original_icon': None, 'original_name': 'Last HVAC activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', @@ -1432,6 +1469,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777999_res_state', @@ -1460,6 +1498,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777999_res_state_code', @@ -1694,6 +1733,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -1727,6 +1767,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', @@ -1766,6 +1807,7 @@ 'original_icon': 'mdi:flash-off', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777999_charge_state', @@ -1796,6 +1838,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', @@ -1826,6 +1869,7 @@ 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1aaaaa555777999_charging_power', @@ -1861,6 +1905,7 @@ 'original_icon': 'mdi:power-plug-off', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777999_plug_state', @@ -1891,6 +1936,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777999_battery_autonomy', @@ -1921,6 +1967,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777999_battery_available_energy', @@ -1951,6 +1998,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777999_battery_temperature', @@ -1979,6 +2027,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777999_battery_last_activity', @@ -2009,6 +2058,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777999_mileage', @@ -2039,6 +2089,7 @@ 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1aaaaa555777999_outside_temperature', @@ -2067,6 +2118,7 @@ 'original_icon': None, 'original_name': 'HVAC SoC threshold', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', @@ -2095,6 +2147,7 @@ 'original_icon': None, 'original_name': 'Last HVAC activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', @@ -2123,6 +2176,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777999_location_last_activity', @@ -2151,6 +2205,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777999_res_state', @@ -2179,6 +2234,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777999_res_state_code', @@ -2424,6 +2480,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -2457,6 +2514,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777123_mileage', @@ -2487,6 +2545,7 @@ 'original_icon': 'mdi:gas-station', 'original_name': 'Fuel autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', @@ -2517,6 +2576,7 @@ 'original_icon': 'mdi:fuel', 'original_name': 'Fuel quantity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1aaaaa555777123_fuel_quantity', @@ -2545,6 +2605,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777123_location_last_activity', @@ -2573,6 +2634,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777123_res_state', @@ -2601,6 +2663,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777123_res_state_code', @@ -2708,6 +2771,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -2741,6 +2805,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_battery_level', @@ -2780,6 +2845,7 @@ 'original_icon': 'mdi:flash', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777123_charge_state', @@ -2810,6 +2876,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', @@ -2840,6 +2907,7 @@ 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1aaaaa555777123_charging_power', @@ -2875,6 +2943,7 @@ 'original_icon': 'mdi:power-plug', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777123_plug_state', @@ -2905,6 +2974,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777123_battery_autonomy', @@ -2935,6 +3005,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777123_battery_available_energy', @@ -2965,6 +3036,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777123_battery_temperature', @@ -2993,6 +3065,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777123_battery_last_activity', @@ -3023,6 +3096,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777123_mileage', @@ -3053,6 +3127,7 @@ 'original_icon': 'mdi:gas-station', 'original_name': 'Fuel autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', @@ -3083,6 +3158,7 @@ 'original_icon': 'mdi:fuel', 'original_name': 'Fuel quantity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1aaaaa555777123_fuel_quantity', @@ -3111,6 +3187,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777123_location_last_activity', @@ -3139,6 +3216,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777123_res_state', @@ -3167,6 +3245,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777123_res_state_code', @@ -3405,6 +3484,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -3438,6 +3518,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', @@ -3477,6 +3558,7 @@ 'original_icon': 'mdi:flash', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777999_charge_state', @@ -3507,6 +3589,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', @@ -3537,6 +3620,7 @@ 'original_icon': None, 'original_name': 'Charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_power', 'unique_id': 'vf1aaaaa555777999_charging_power', @@ -3572,6 +3656,7 @@ 'original_icon': 'mdi:power-plug', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777999_plug_state', @@ -3602,6 +3687,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777999_battery_autonomy', @@ -3632,6 +3718,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777999_battery_available_energy', @@ -3662,6 +3749,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777999_battery_temperature', @@ -3690,6 +3778,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777999_battery_last_activity', @@ -3720,6 +3809,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777999_mileage', @@ -3750,6 +3840,7 @@ 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1aaaaa555777999_outside_temperature', @@ -3778,6 +3869,7 @@ 'original_icon': None, 'original_name': 'HVAC SoC threshold', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', @@ -3806,6 +3898,7 @@ 'original_icon': None, 'original_name': 'Last HVAC activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', @@ -3834,6 +3927,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777999_res_state', @@ -3862,6 +3956,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777999_res_state_code', @@ -4096,6 +4191,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -4129,6 +4225,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', @@ -4168,6 +4265,7 @@ 'original_icon': 'mdi:flash-off', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777999_charge_state', @@ -4198,6 +4296,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', @@ -4228,6 +4327,7 @@ 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1aaaaa555777999_charging_power', @@ -4263,6 +4363,7 @@ 'original_icon': 'mdi:power-plug-off', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777999_plug_state', @@ -4293,6 +4394,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777999_battery_autonomy', @@ -4323,6 +4425,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777999_battery_available_energy', @@ -4353,6 +4456,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777999_battery_temperature', @@ -4381,6 +4485,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777999_battery_last_activity', @@ -4411,6 +4516,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777999_mileage', @@ -4441,6 +4547,7 @@ 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1aaaaa555777999_outside_temperature', @@ -4469,6 +4576,7 @@ 'original_icon': None, 'original_name': 'HVAC SoC threshold', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', @@ -4497,6 +4605,7 @@ 'original_icon': None, 'original_name': 'Last HVAC activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', @@ -4525,6 +4634,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777999_location_last_activity', @@ -4553,6 +4663,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777999_res_state', @@ -4581,6 +4692,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777999_res_state_code', diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 7cf94dcf846..c43fe84ea8f 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -1,11 +1,16 @@ """The tests for the rest command platform.""" import asyncio from http import HTTPStatus +from unittest.mock import patch import aiohttp import homeassistant.components.rest_command as rc -from homeassistant.const import CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN +from homeassistant.const import ( + CONTENT_TYPE_JSON, + CONTENT_TYPE_TEXT_PLAIN, + SERVICE_RELOAD, +) from homeassistant.setup import setup_component from tests.common import assert_setup_component, get_test_home_assistant @@ -43,6 +48,30 @@ class TestRestCommandSetup: assert self.hass.services.has_service(rc.DOMAIN, "test_get") + def test_reload(self): + """Verify we can reload rest_command integration.""" + + with assert_setup_component(1): + setup_component(self.hass, rc.DOMAIN, self.config) + + assert self.hass.services.has_service(rc.DOMAIN, "test_get") + assert not self.hass.services.has_service(rc.DOMAIN, "new_test") + + new_config = { + rc.DOMAIN: { + "new_test": {"url": "https://example.org", "method": "get"}, + } + } + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=new_config, + ): + self.hass.services.call(rc.DOMAIN, SERVICE_RELOAD, blocking=True) + + assert self.hass.services.has_service(rc.DOMAIN, "new_test") + assert not self.hass.services.has_service(rc.DOMAIN, "get_test") + class TestRestCommandComponent: """Test the rest command component.""" diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 4edf8ff4710..e70dac5ffc9 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -9,7 +9,7 @@ async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: """Test binary sensors and check test values are correctly set.""" - assert len(hass.states.async_all("binary_sensor")) == 6 + assert len(hass.states.async_all("binary_sensor")) == 8 assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state @@ -18,3 +18,4 @@ async def test_binary_sensors( assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" ) + assert hass.states.get("binary_sensor.roborock_s7_maxv_cleaning").state == "off" diff --git a/tests/components/safe_mode/__init__.py b/tests/components/safe_mode/__init__.py deleted file mode 100644 index 3732fef17cb..00000000000 --- a/tests/components/safe_mode/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Safe Mode integration.""" diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index f8b11bd864a..181cf8de17b 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -36,7 +36,7 @@ 'domain': 'media_player', 'entity_category': None, 'entity_id': 'media_player.any', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -45,8 +45,9 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'any', + 'original_name': None, 'platform': 'samsungtv', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'sample-entry-id', diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index bf32d76836c..0972aa97033 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -3,8 +3,6 @@ from datetime import timedelta from unittest.mock import Mock -from pyschlage.exceptions import UnknownError - from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK @@ -62,26 +60,8 @@ async def test_changed_by( # Make the coordinator refresh data. async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() - mock_lock.last_changed_by.assert_called_once_with([]) + mock_lock.last_changed_by.assert_called_once_with() lock_device = hass.states.get("lock.vault_door") assert lock_device is not None assert lock_device.attributes.get("changed_by") == "access code - foo" - - -async def test_changed_by_uses_previous_logs_on_failure( - hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry -) -> None: - """Test that a failure to load logs is not terminal.""" - mock_lock.last_changed_by.reset_mock() - mock_lock.last_changed_by.return_value = "thumbturn" - mock_lock.logs.side_effect = UnknownError("Cannot load logs") - - # Make the coordinator refresh data. - async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() - mock_lock.last_changed_by.assert_called_once_with([]) - - lock_device = hass.states.get("lock.vault_door") - assert lock_device is not None - assert lock_device.attributes.get("changed_by") == "thumbturn" diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index 9e1895f3a58..7dd2954f8c3 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -8,7 +8,6 @@ from homeassistant import config_entries from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN -from homeassistant.components.scrape.config_flow import NONE_SENTINEL from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -71,9 +70,6 @@ async def test_form( CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -132,9 +128,6 @@ async def test_form_with_post( CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -226,9 +219,6 @@ async def test_flow_fails( CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -350,9 +340,6 @@ async def test_options_add_remove_sensor_flow( CONF_NAME: "Template", CONF_SELECT: "template", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -480,9 +467,6 @@ async def test_options_edit_sensor_flow( user_input={ CONF_SELECT: "template", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -646,9 +630,6 @@ async def test_sensor_options_remove_device_class( CONF_SELECT: ".current-temp h3", CONF_INDEX: 0.0, CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py index aa4be4cdef3..638e25a6e05 100644 --- a/tests/components/scrape/test_init.py +++ b/tests/components/scrape/test_init.py @@ -8,13 +8,14 @@ import pytest from homeassistant import config_entries from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import MockRestData, return_integration_config from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator async def test_setup_config(hass: HomeAssistant) -> None: @@ -125,3 +126,48 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) assert await hass.config_entries.async_unload(loaded_entry.entry_id) await hass.async_block_till_done() assert loaded_entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + +async def test_device_remove_devices( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + registry: er.EntityRegistry = er.async_get(hass) + entity = registry.entities["sensor.current_version"] + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, loaded_entry.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=loaded_entry.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, loaded_entry.entry_id + ) + is True + ) diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 8368eb06140..b248a3d7650 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -7,14 +7,15 @@ from unittest.mock import patch import pytest +from homeassistant import config_entries from homeassistant.components import script from homeassistant.components.blueprint.models import Blueprint, DomainBlueprints from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers import template +from homeassistant.helpers import device_registry as dr, template from homeassistant.setup import async_setup_component from homeassistant.util import yaml -from tests.common import async_mock_service +from tests.common import MockConfigEntry, async_mock_service BUILTIN_BLUEPRINT_FOLDER = pathlib.Path(script.__file__).parent / "blueprints" @@ -41,8 +42,19 @@ def patch_blueprint(blueprint_path: str, data_path: str) -> Iterator[None]: yield -async def test_confirmable_notification(hass: HomeAssistant) -> None: +async def test_confirmable_notification( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test confirmable notification blueprint.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + frodo = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) + with patch_blueprint( "confirmable_notification.yaml", BUILTIN_BLUEPRINT_FOLDER / "confirmable_notification.yaml", @@ -56,7 +68,7 @@ async def test_confirmable_notification(hass: HomeAssistant) -> None: "use_blueprint": { "path": "confirmable_notification.yaml", "input": { - "notify_device": "frodo", + "notify_device": frodo.id, "title": "Lord of the things", "message": "Throw ring in mountain?", "confirm_action": [ @@ -105,7 +117,7 @@ async def test_confirmable_notification(hass: HomeAssistant) -> None: "alias": "Send notification", "domain": "mobile_app", "type": "notify", - "device_id": "frodo", + "device_id": frodo.id, "data": { "actions": [ {"action": "CONFIRM_" + _context.id, "title": "Confirm"}, diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index cddefc8d3dc..83abd37137e 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch import pytest +from homeassistant import config_entries from homeassistant.components import script from homeassistant.components.script import DOMAIN, EVENT_SCRIPT_STARTED, ScriptEntity from homeassistant.const import ( @@ -27,7 +28,7 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.exceptions import ServiceNotFound -from homeassistant.helpers import entity_registry as er, template +from homeassistant.helpers import device_registry as dr, entity_registry as er, template from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import ( SCRIPT_MODE_CHOICES, @@ -42,7 +43,12 @@ from homeassistant.setup import async_setup_component from homeassistant.util import yaml import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, async_mock_service, mock_restore_cache +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_mock_service, + mock_restore_cache, +) from tests.components.logbook.common import MockRow, mock_humanify from tests.typing import WebSocketGenerator @@ -707,8 +713,23 @@ async def test_extraction_functions_unavailable_script(hass: HomeAssistant) -> N assert script.entities_in_script(hass, entity_id) == [] -async def test_extraction_functions(hass: HomeAssistant) -> None: +async def test_extraction_functions( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test extraction functions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + device_in_both = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")}, + ) + device_in_last = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:03")}, + ) + assert await async_setup_component( hass, DOMAIN, @@ -728,7 +749,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "entity_id": "light.device_in_both", "domain": "light", "type": "turn_on", - "device_id": "device-in-both", + "device_id": device_in_both.id, }, { "service": "test.test", @@ -752,13 +773,13 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "entity_id": "light.device_in_both", "domain": "light", "type": "turn_on", - "device_id": "device-in-both", + "device_id": device_in_both.id, }, { "entity_id": "light.device_in_last", "domain": "light", "type": "turn_on", - "device_id": "device-in-last", + "device_id": device_in_last.id, }, ], }, @@ -797,13 +818,13 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "light.in_both", "light.in_first", } - assert set(script.scripts_with_device(hass, "device-in-both")) == { + assert set(script.scripts_with_device(hass, device_in_both.id)) == { "script.test1", "script.test2", } assert set(script.devices_in_script(hass, "script.test2")) == { - "device-in-both", - "device-in-last", + device_in_both.id, + device_in_last.id, } assert set(script.scripts_with_area(hass, "area-in-both")) == { "script.test1", diff --git a/tests/components/select/test_device_action.py b/tests/components/select/test_device_action.py index ce5d48bb358..121b41fcb2b 100644 --- a/tests/components/select/test_device_action.py +++ b/tests/components/select/test_device_action.py @@ -116,10 +116,21 @@ async def test_get_actions_hidden_auxiliary( @pytest.mark.parametrize("action_type", ("select_first", "select_last")) async def test_action_select_first_last( - hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action_type: str, ) -> None: """Test for select_first and select_last actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -133,7 +144,7 @@ async def test_action_select_first_last( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": action_type, }, @@ -154,10 +165,21 @@ async def test_action_select_first_last( @pytest.mark.parametrize("action_type", ("select_first", "select_last")) async def test_action_select_first_last_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action_type: str, ) -> None: """Test for select_first and select_last actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -171,7 +193,7 @@ async def test_action_select_first_last_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": action_type, }, @@ -191,10 +213,20 @@ async def test_action_select_first_last_legacy( async def test_action_select_option( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for select_option action.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -208,7 +240,7 @@ async def test_action_select_option( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "select_option", "option": "option1", @@ -230,10 +262,21 @@ async def test_action_select_option( @pytest.mark.parametrize("action_type", ["select_next", "select_previous"]) async def test_action_select_next_previous( - hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action_type: str, ) -> None: """Test for select_next and select_previous actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -247,7 +290,7 @@ async def test_action_select_next_previous( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": action_type, "cycle": False, diff --git a/tests/components/select/test_device_condition.py b/tests/components/select/test_device_condition.py index 18ebd428891..3e0ecd6e547 100644 --- a/tests/components/select/test_device_condition.py +++ b/tests/components/select/test_device_condition.py @@ -115,10 +115,19 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_selected_option( hass: HomeAssistant, calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for selected_option conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -131,7 +140,7 @@ async def test_if_selected_option( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "selected_option", "option": "option1", @@ -150,7 +159,7 @@ async def test_if_selected_option( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "selected_option", "option": "option2", @@ -195,10 +204,19 @@ async def test_if_selected_option( async def test_if_selected_option_legacy( hass: HomeAssistant, calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for selected_option conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -211,7 +229,7 @@ async def test_if_selected_option_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "selected_option", "option": "option1", diff --git a/tests/components/select/test_device_trigger.py b/tests/components/select/test_device_trigger.py index 8a6ccd43abe..0be5c605dc1 100644 --- a/tests/components/select/test_device_trigger.py +++ b/tests/components/select/test_device_trigger.py @@ -113,10 +113,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, "option1", {"options": ["option1", "option2", "option3"]} @@ -131,7 +142,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_option_changed", "to": "option2", @@ -152,7 +163,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_option_changed", "from": "option2", @@ -173,7 +184,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_option_changed", "from": "option3", @@ -224,10 +235,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, "option1", {"options": ["option1", "option2", "option3"]} @@ -242,7 +264,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "current_option_changed", "to": "option2", diff --git a/tests/components/sensibo/test_init.py b/tests/components/sensibo/test_init.py index 505816e3f41..90dbcd86a96 100644 --- a/tests/components/sensibo/test_init.py +++ b/tests/components/sensibo/test_init.py @@ -8,12 +8,15 @@ from pysensibo.model import SensiboData from homeassistant import config_entries from homeassistant.components.sensibo.const import DOMAIN from homeassistant.components.sensibo.util import NoUsernameError -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from . import ENTRY_CONFIG from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_setup_entry(hass: HomeAssistant, get_data: SensiboData) -> None: @@ -131,3 +134,48 @@ async def test_unload_entry(hass: HomeAssistant, get_data: SensiboData) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + +async def test_device_remove_devices( + hass: HomeAssistant, + load_int: ConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + registry: er.EntityRegistry = er.async_get(hass) + entity = registry.entities["climate.hallway"] + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, load_int.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=load_int.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, load_int.entry_id + ) + is True + ) diff --git a/tests/components/sensibo/test_update.py b/tests/components/sensibo/test_update.py index c65ee5995ee..72e9ae9f902 100644 --- a/tests/components/sensibo/test_update.py +++ b/tests/components/sensibo/test_update.py @@ -15,7 +15,7 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_select( +async def test_update( hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, @@ -23,8 +23,8 @@ async def test_select( ) -> None: """Test the Sensibo update.""" - state1 = hass.states.get("update.hallway_update_available") - state2 = hass.states.get("update.kitchen_update_available") + state1 = hass.states.get("update.hallway_firmware") + state2 = hass.states.get("update.kitchen_firmware") assert state1.state == STATE_ON assert state1.attributes["installed_version"] == "SKY30046" assert state1.attributes["latest_version"] == "SKY30048" @@ -43,5 +43,5 @@ async def test_select( ) await hass.async_block_till_done() - state1 = hass.states.get("update.hallway_update_available") + state1 = hass.states.get("update.hallway_firmware") assert state1.state == STATE_OFF diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 301baf0fc49..e0a8bebf5fc 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -461,13 +461,22 @@ async def test_get_condition_capabilities_none( async def test_if_state_not_above_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, ) -> None: """Test for bad value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -480,7 +489,7 @@ async def test_if_state_not_above_below( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", } @@ -495,12 +504,21 @@ async def test_if_state_not_above_below( async def test_if_state_above( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -515,7 +533,7 @@ async def test_if_state_above( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", "above": 10, @@ -553,12 +571,21 @@ async def test_if_state_above( async def test_if_state_above_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -573,7 +600,7 @@ async def test_if_state_above_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_battery_level", "above": 10, @@ -611,12 +638,21 @@ async def test_if_state_above_legacy( async def test_if_state_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -631,7 +667,7 @@ async def test_if_state_below( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", "below": 10, @@ -669,12 +705,21 @@ async def test_if_state_below( async def test_if_state_between( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -689,7 +734,7 @@ async def test_if_state_between( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", "above": 10, diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 7045d71fb78..bbc59cca322 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -418,13 +418,22 @@ async def test_get_trigger_capabilities_none( async def test_if_fires_not_on_above_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -435,7 +444,7 @@ async def test_if_fires_not_on_above_below( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", }, @@ -449,12 +458,21 @@ async def test_if_fires_not_on_above_below( async def test_if_fires_on_state_above( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -467,7 +485,7 @@ async def test_if_fires_on_state_above( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "above": 10, @@ -508,12 +526,21 @@ async def test_if_fires_on_state_above( async def test_if_fires_on_state_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -526,7 +553,7 @@ async def test_if_fires_on_state_below( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "below": 10, @@ -567,12 +594,21 @@ async def test_if_fires_on_state_below( async def test_if_fires_on_state_between( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -585,7 +621,7 @@ async def test_if_fires_on_state_between( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "above": 10, @@ -638,12 +674,21 @@ async def test_if_fires_on_state_between( async def test_if_fires_on_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -656,7 +701,7 @@ async def test_if_fires_on_state_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "battery_level", "above": 10, @@ -697,12 +742,21 @@ async def test_if_fires_on_state_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -715,7 +769,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "above": 10, diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 01dfb9b3649..fc714a543bf 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -26,6 +26,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNKNOWN, + EntityCategory, UnitOfEnergy, UnitOfLength, UnitOfMass, @@ -166,7 +167,7 @@ async def test_deprecated_last_reset( f"with state_class {state_class} has set last_reset. Setting last_reset for " "entities with state_class other than 'total' is not supported. Please update " "your configuration if state_class is manually configured, otherwise report it " - "to the custom integration author" + "to the author of the 'test' custom integration" ) in caplog.text state = hass.states.get("sensor.test") @@ -2496,3 +2497,25 @@ def test_device_class_units_state_classes(hass: HomeAssistant) -> None: ) - NON_NUMERIC_DEVICE_CLASSES - {SensorDeviceClass.MONETARY} # DEVICE_CLASS_STATE_CLASSES should include all device classes assert set(DEVICE_CLASS_STATE_CLASSES) == set(SensorDeviceClass) + + +async def test_entity_category_config_raises_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error is raised when entity category is set to config.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", entity_category=EntityCategory.CONFIG + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Entity sensor.test cannot be added as the entity category is set to config" + in caplog.text + ) + + assert not hass.states.get("sensor.test") diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 1c0200e1b53..34aaeda6740 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1337,7 +1337,7 @@ def test_compile_hourly_sum_statistics_nan_inf_state( "energy", 0, "from integration test ", - "report it to the custom integration author", + "report it to the author of the 'test' custom integration", ), ], ) diff --git a/tests/components/sensor/test_websocket_api.py b/tests/components/sensor/test_websocket_api.py index 17b8a2ab5cb..bd0a68598e1 100644 --- a/tests/components/sensor/test_websocket_api.py +++ b/tests/components/sensor/test_websocket_api.py @@ -1,5 +1,11 @@ """Test the sensor websocket API.""" -from homeassistant.components.sensor.const import DOMAIN +from pytest_unordered import unordered + +from homeassistant.components.sensor.const import ( + DOMAIN, + NON_NUMERIC_DEVICE_CLASSES, + SensorDeviceClass, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -59,3 +65,22 @@ async def test_device_class_units( msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"units": []} + + +async def test_numeric_device_classes( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we can get numeric device classes.""" + numeric_device_classes = set(SensorDeviceClass) - NON_NUMERIC_DEVICE_CLASSES + + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + # Device class with units which sensor allows customizing & converting + await client.send_json_auto_id({"type": "sensor/numeric_device_classes"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "numeric_device_classes": unordered(list(numeric_device_classes)) + } diff --git a/tests/components/sentry/test_init.py b/tests/components/sentry/test_init.py index 25b77922878..73f6a7cfd09 100644 --- a/tests/components/sentry/test_init.py +++ b/tests/components/sentry/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components.sentry import get_channel, process_before_send +from homeassistant.components.sentry import process_before_send from homeassistant.components.sentry.const import ( CONF_DSN, CONF_ENVIRONMENT, @@ -103,20 +103,6 @@ async def test_setup_entry_with_tracing(hass: HomeAssistant) -> None: assert call_args["traces_sample_rate"] == 0.5 -@pytest.mark.parametrize( - ("version", "channel"), - [ - ("0.115.0.dev20200815", "nightly"), - ("0.115.0", "stable"), - ("0.115.0b4", "beta"), - ("0.115.0dev0", "dev"), - ], -) -async def test_get_channel(version: str, channel: str) -> None: - """Test if channel detection works from Home Assistant version number.""" - assert get_channel(version) == channel - - async def test_process_before_send(hass: HomeAssistant) -> None: """Test regular use of the Sentry process before sending function.""" hass.config.components.add("puppies") diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index b308b5ab3af..4eee1208a12 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, @@ -53,6 +54,7 @@ 'original_icon': None, 'original_name': 'WAN status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wan_status', 'unique_id': 'e4:5d:51:00:11:22_wan_status', @@ -81,6 +83,7 @@ 'original_icon': None, 'original_name': 'DSL status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_status', 'unique_id': 'e4:5d:51:00:11:22_dsl_status', @@ -137,6 +140,7 @@ 'model': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, @@ -168,6 +172,7 @@ 'original_icon': None, 'original_name': 'WAN status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wan_status', 'unique_id': 'e4:5d:51:00:11:22_wan_status', @@ -196,6 +201,7 @@ 'original_icon': None, 'original_name': 'FTTH status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'ftth_status', 'unique_id': 'e4:5d:51:00:11:22_ftth_status', diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index f362cfc146f..846da8d41cf 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -22,6 +22,7 @@ 'model': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, @@ -53,6 +54,7 @@ 'original_icon': None, 'original_name': 'Restart', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_reboot', diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 171a5803ada..2b1825a40b4 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, @@ -60,6 +61,7 @@ 'original_icon': None, 'original_name': 'Network infrastructure', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'net_infra', 'unique_id': 'e4:5d:51:00:11:22_system_net_infra', @@ -88,6 +90,7 @@ 'original_icon': None, 'original_name': 'Voltage', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_alimvoltage', @@ -116,6 +119,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_temperature', @@ -152,6 +156,7 @@ 'original_icon': None, 'original_name': 'WAN mode', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wan_mode', 'unique_id': 'e4:5d:51:00:11:22_wan_mode', @@ -180,6 +185,7 @@ 'original_icon': None, 'original_name': 'DSL line mode', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_linemode', 'unique_id': 'e4:5d:51:00:11:22_dsl_linemode', @@ -208,6 +214,7 @@ 'original_icon': None, 'original_name': 'DSL counter', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_counter', 'unique_id': 'e4:5d:51:00:11:22_dsl_counter', @@ -236,6 +243,7 @@ 'original_icon': None, 'original_name': 'DSL CRC', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_crc', 'unique_id': 'e4:5d:51:00:11:22_dsl_crc', @@ -266,6 +274,7 @@ 'original_icon': None, 'original_name': 'DSL noise down', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_noise_down', 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_down', @@ -296,6 +305,7 @@ 'original_icon': None, 'original_name': 'DSL noise up', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_noise_up', 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_up', @@ -326,6 +336,7 @@ 'original_icon': None, 'original_name': 'DSL attenuation down', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_attenuation_down', 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_down', @@ -356,6 +367,7 @@ 'original_icon': None, 'original_name': 'DSL attenuation up', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_attenuation_up', 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_up', @@ -386,6 +398,7 @@ 'original_icon': None, 'original_name': 'DSL rate down', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_rate_down', 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_down', @@ -416,6 +429,7 @@ 'original_icon': None, 'original_name': 'DSL rate up', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_rate_up', 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_up', @@ -453,6 +467,7 @@ 'original_icon': None, 'original_name': 'DSL line status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_line_status', 'unique_id': 'e4:5d:51:00:11:22_dsl_line_status', @@ -494,6 +509,7 @@ 'original_icon': None, 'original_name': 'DSL training', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_training', 'unique_id': 'e4:5d:51:00:11:22_dsl_training', diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 3872f6f5a1a..8ce80b70032 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -23,8 +23,10 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, async_entries_for_config_entry, async_get as async_get_dev_reg, + format_mac, ) import homeassistant.helpers.issue_registry as ir @@ -632,3 +634,34 @@ async def test_rpc_polling_disconnected( await mock_polling_rpc_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_rpc_update_entry_fw_ver( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test RPC update entry firmware version.""" + entry = await init_integration(hass, 2, sleep_period=600) + dev_reg = async_get_dev_reg(hass) + + # Make device online + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + device = dev_reg.async_get_device( + identifiers={(DOMAIN, entry.entry_id)}, + connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, + ) + + assert device.sw_version == "some fw string" + + monkeypatch.setattr(mock_rpc_device, "firmware_version", "99.0.0") + + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + device = dev_reg.async_get_device( + identifiers={(DOMAIN, entry.entry_id)}, + connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, + ) + + assert device.sw_version == "99.0.0" diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index a738113f18f..380f4f5999e 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1,9 +1,15 @@ """Tests for Shelly sensor platform.""" from freezegun.api import FrozenDateTimeFactory +import pytest +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, @@ -12,6 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.entity_registry import async_get +from homeassistant.setup import async_setup_component from . import ( init_integration, @@ -448,3 +455,76 @@ async def test_rpc_em1_sensors( entry = registry.async_get("sensor.test_name_em1_total_active_energy") assert entry assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" + + +async def test_rpc_sleeping_update_entity_service( + hass: HomeAssistant, mock_rpc_device, caplog: pytest.LogCaptureFixture +) -> None: + """Test RPC sleeping device when the update_entity service is used.""" + await async_setup_component(hass, "homeassistant", {}) + + entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" + await init_integration(hass, 2, sleep_period=1000) + + # Entity should be created when device is online + assert hass.states.get(entity_id) is None + + # Make device online + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "22.9" + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Entity should be available after update_entity service call + state = hass.states.get(entity_id) + assert state.state == "22.9" + + assert ( + "Entity sensor.test_name_temperature comes from a sleeping device" + in caplog.text + ) + + +async def test_block_sleeping_update_entity_service( + hass: HomeAssistant, mock_block_device, caplog: pytest.LogCaptureFixture +) -> None: + """Test block sleeping device when the update_entity service is used.""" + await async_setup_component(hass, "homeassistant", {}) + + entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" + await init_integration(hass, 1, sleep_period=1000) + + # Sensor should be created when device is online + assert hass.states.get(entity_id) is None + + # Make device online + mock_block_device.mock_update() + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "22.1" + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Entity should be available after update_entity service call + state = hass.states.get(entity_id) + assert state.state == "22.1" + + assert ( + "Entity sensor.test_name_temperature comes from a sleeping device" + in caplog.text + ) diff --git a/tests/components/shopping_list/conftest.py b/tests/components/shopping_list/conftest.py index 596a8c87cd3..aec55362d0b 100644 --- a/tests/components/shopping_list/conftest.py +++ b/tests/components/shopping_list/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from homeassistant.components.shopping_list import intent as sl_intent +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -18,12 +19,17 @@ def mock_shopping_list_io(): @pytest.fixture -async def sl_setup(hass): +def mock_config_entry() -> MockConfigEntry: + """Config Entry fixture.""" + return MockConfigEntry(domain="shopping_list") + + +@pytest.fixture +async def sl_setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry): """Set up the shopping list.""" - entry = MockConfigEntry(domain="shopping_list") - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await sl_intent.async_setup_intents(hass) diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py new file mode 100644 index 00000000000..681ccea60ac --- /dev/null +++ b/tests/components/shopping_list/test_todo.py @@ -0,0 +1,477 @@ +"""Test shopping list todo platform.""" + +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + +TEST_ENTITY = "todo.shopping_list" + + +@pytest.fixture +def ws_req_id() -> Callable[[], int]: + """Fixture for incremental websocket requests.""" + + id = 0 + + def next() -> int: + nonlocal id + id += 1 + return id + + return next + + +@pytest.fixture +async def ws_get_items( + hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + await client.send_json( + { + "id": id, + "type": "todo/item/list", + "entity_id": TEST_ENTITY, + } + ) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get + + +@pytest.fixture +async def ws_move_item( + hass_ws_client: WebSocketGenerator, + ws_req_id: Callable[[], int], +) -> Callable[[str, str | None], Awaitable[None]]: + """Fixture to move an item in the todo list.""" + + async def move(uid: str, previous_uid: str | None) -> dict[str, Any]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + data = { + "id": id, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": uid, + } + if previous_uid is not None: + data["previous_uid"] = previous_uid + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == id + return resp + + return move + + +async def test_get_items( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test creating a shopping list item with the WS API and verifying with To-do API.""" + client = await hass_ws_client(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + # Native shopping list websocket + await client.send_json( + {"id": ws_req_id(), "type": "shopping_list/items/add", "name": "soda"} + ) + msg = await client.receive_json() + assert msg["success"] is True + data = msg["result"] + assert data["name"] == "soda" + assert data["complete"] is False + + # Fetch items using To-do platform + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_add_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test adding shopping_list item and listing it.""" + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + { + "item": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch items using To-do platform + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_remove_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test removing a todo item.""" + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + { + "item": [items[0]["uid"]], + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_bulk_remove( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test removing a todo item.""" + + for _i in range(0, 5): + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + { + "item": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 5 + uids = [item["uid"] for item in items] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "5" + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + { + "item": uids, + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_update_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + { + "item": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Mark item completed + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "soda", + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is marked as completed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_partial_update_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test updating a todo item with partial information.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + { + "item": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Mark item completed without changing the summary + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": item["uid"], + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is marked as completed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + # Change the summary without changing the status + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": item["uid"], + "rename": "other summary", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is changed and still marked as completed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "other summary" + assert item["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_update_invalid_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test updating a todo item that does not exist.""" + + with pytest.raises(ValueError, match="Unable to find"): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "invalid-uid", + "rename": "Example task", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("src_idx", "dst_idx", "expected_items"), + [ + # Move any item to the front of the list + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), + # Move items right + (0, 1, ["item 2", "item 1", "item 3", "item 4"]), + (0, 2, ["item 2", "item 3", "item 1", "item 4"]), + (0, 3, ["item 2", "item 3", "item 4", "item 1"]), + (1, 2, ["item 1", "item 3", "item 2", "item 4"]), + (1, 3, ["item 1", "item 3", "item 4", "item 2"]), + # Move items left + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), + # No-ops + (0, 0, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), + (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 3, ["item 1", "item 2", "item 3", "item 4"]), + ], +) +async def test_move_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + ws_move_item: Callable[[str, str | None], Awaitable[dict[str, Any]]], + src_idx: int, + dst_idx: int | None, + expected_items: list[str], +) -> None: + """Test moving a todo item within the list.""" + + for i in range(1, 5): + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + { + "item": f"item {i}", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 4 + uids = [item["uid"] for item in items] + summaries = [item["summary"] for item in items] + assert summaries == ["item 1", "item 2", "item 3", "item 4"] + + # Prepare items for moving + previous_uid: str | None = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + + resp = await ws_move_item(uids[src_idx], previous_uid) + assert resp.get("success") + + items = await ws_get_items() + assert len(items) == 4 + summaries = [item["summary"] for item in items] + assert summaries == expected_items + + +async def test_move_invalid_item( + hass: HomeAssistant, + sl_setup: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + ws_move_item: Callable[[str, int | None], Awaitable[dict[str, Any]]], +) -> None: + """Test moving an item that does not exist.""" + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + + resp = await ws_move_item("unknown", 0) + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "could not be re-ordered" in resp.get("error", {}).get("message") diff --git a/tests/components/smarttub/test_init.py b/tests/components/smarttub/test_init.py index 0e88f3ed7c7..929ad687e11 100644 --- a/tests/components/smarttub/test_init.py +++ b/tests/components/smarttub/test_init.py @@ -42,6 +42,7 @@ async def test_setup_auth_failed( config_entry.add_to_hass(hass) with patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_ERROR mock_flow_init.assert_called_with( DOMAIN, diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py index b6ff43503a6..bb07eae2140 100644 --- a/tests/components/snapcast/test_config_flow.py +++ b/tests/components/snapcast/test_config_flow.py @@ -93,18 +93,3 @@ async def test_abort( 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/snmp/conftest.py b/tests/components/snmp/conftest.py new file mode 100644 index 00000000000..05a518ad7f3 --- /dev/null +++ b/tests/components/snmp/conftest.py @@ -0,0 +1,5 @@ +"""Skip test collection for Python 3.12.""" +import sys + +if sys.version_info >= (3, 12): + collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index a3f74127283..f6b4db84630 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,5 +1,6 @@ """Tests for the Sonos config flow.""" import asyncio +from datetime import timedelta import logging from unittest.mock import Mock, patch @@ -17,9 +18,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util from .conftest import MockSoCo, SoCoMockFactory +from tests.common import async_fire_time_changed + async def test_creating_entry_sets_up_media_player( hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo @@ -322,16 +326,19 @@ async def test_async_poll_manual_hosts_5( # Speed up manual discovery interval so second iteration runs sooner mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60]) - await _setup_hass(hass) - - assert "media_player.bedroom" in entity_registry.entities - assert "media_player.living_room" in entity_registry.entities - with caplog.at_level(logging.DEBUG): caplog.clear() - await speaker_1_activity.event.wait() - await speaker_2_activity.event.wait() + + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=0.5)) await hass.async_block_till_done() + await asyncio.gather( + *[speaker_1_activity.event.wait(), speaker_2_activity.event.wait()] + ) assert speaker_1_activity.call_count == 1 assert speaker_2_activity.call_count == 1 assert "Activity on Living Room" in caplog.text diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 915394863ea..6517e319fe4 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -8,7 +8,6 @@ from sqlalchemy.exc import SQLAlchemyError from homeassistant import config_entries from homeassistant.components.recorder import Recorder from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass -from homeassistant.components.sql.config_flow import NONE_SENTINEL from homeassistant.components.sql.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -669,8 +668,6 @@ async def test_device_state_class(recorder_mock: Recorder, hass: HomeAssistant) "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "device_class": NONE_SENTINEL, - "state_class": NONE_SENTINEL, }, ) await hass.async_block_till_done() diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 780e550f224..13330770978 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -22,6 +22,7 @@ from homeassistant.components.statistics.sensor import StatisticsSensor from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + DEGREE, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -920,6 +921,14 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: "value_9": float(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)), "unit": "°C", }, + { + "source_sensor_domain": "sensor", + "name": "mean_circular", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": 10.76, + "unit": "°C", + }, { "source_sensor_domain": "sensor", "name": "median", @@ -1207,6 +1216,43 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: ) +async def test_state_characteristic_mean_circular(hass: HomeAssistant) -> None: + """Test the mean_circular state characteristic using angle data.""" + values_angular = [0, 10, 90.5, 180, 269.5, 350] + + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_sensor_mean_circular", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean_circular", + "sampling_size": 6, + }, + ] + }, + ) + await hass.async_block_till_done() + + for angle in values_angular: + hass.states.async_set( + "sensor.test_monitored", + str(angle), + {ATTR_UNIT_OF_MEASUREMENT: DEGREE}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor_mean_circular") + assert state is not None + assert state.state == "0.0", ( + "value mismatch for characteristic 'sensor/mean_circular' - " + f"assert {state.state} == 0.0" + ) + + async def test_invalid_state_characteristic(hass: HomeAssistant) -> None: """Test the detection of wrong state_characteristics selected.""" assert await async_setup_component( diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index 7ea583c0ec3..ae4a4fc2d9d 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -24,7 +24,7 @@ DefaultSegment = partial( init=None, stream_id=0, start_time=FAKE_TIME, - stream_outputs=[], + _stream_outputs=[], ) AUDIO_SAMPLE_RATE = 8000 diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index 6559cc3f7e9..b1fb0d2facd 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -138,3 +138,43 @@ async def test_setting_rising( assert ( solar_azimuth_state.state != hass.states.get("sensor.sun_solar_azimuth").state ) + + entity = entity_reg.async_get("sensor.sun_next_dusk") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dusk" + + entity = entity_reg.async_get("sensor.sun_next_midnight") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_midnight" + + entity = entity_reg.async_get("sensor.sun_next_noon") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_noon" + + entity = entity_reg.async_get("sensor.sun_next_rising") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_rising" + + entity = entity_reg.async_get("sensor.sun_next_setting") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_setting" + + entity = entity_reg.async_get("sensor.sun_solar_elevation") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-solar_elevation" + + entity = entity_reg.async_get("sensor.sun_solar_azimuth") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-solar_azimuth" + + entity = entity_reg.async_get("sensor.sun_solar_rising") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-solar_rising" diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index 85799a49a34..e86c32c1e32 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -111,12 +111,21 @@ async def test_get_actions_hidden_auxiliary( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -127,7 +136,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -136,7 +145,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -145,7 +154,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -177,12 +186,21 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -193,7 +211,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index c60954e335f..c9521930a73 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -179,12 +179,21 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -199,7 +208,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -218,7 +227,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -253,12 +262,21 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -273,7 +291,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } @@ -300,6 +318,7 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -309,7 +328,15 @@ async def test_if_fires_on_for_condition( point2 = point1 + timedelta(seconds=10) point3 = point2 + timedelta(seconds=10) - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -324,7 +351,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 32f8f65b114..03f7e8fbb8e 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -177,12 +177,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -195,7 +204,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -219,7 +228,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -243,7 +252,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -288,12 +297,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -306,7 +324,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -343,12 +361,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -361,7 +388,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 48f0021bdb4..e9f0a0a475d 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState +from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState, Remote from homeassistant.components.switchbot_cloud import SwitchBotAPI from homeassistant.config_entries import ConfigEntryState @@ -32,12 +32,24 @@ async def test_setup_entry_success( ) -> None: """Test successful setup of entry.""" mock_list_devices.return_value = [ + Remote( + deviceId="air-conditonner-id-1", + deviceName="air-conditonner-name-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), Device( - deviceId="test-id", - deviceName="test-name", + deviceId="plug-id-1", + deviceName="plug-name-1", deviceType="Plug", hubDeviceId="test-hub-id", - ) + ), + Remote( + deviceId="plug-id-2", + deviceName="plug-name-2", + remoteType="DIY Plug", + hubDeviceId="test-hub-id", + ), ] mock_get_status.return_value = {"power": PowerState.ON.value} entry = configure_integration(hass) diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index bfc3daf0aa2..91556f459ba 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -50,7 +50,10 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None: side_effect=SynologyDSMLoginInvalidException(USERNAME), ), patch( "homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth", - return_value={"type": data_entry_flow.FlowResultType.FORM}, + return_value={ + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "reauth_confirm", + }, ) as mock_async_step_reauth: entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/tami4/__init__.py b/tests/components/tami4/__init__.py new file mode 100644 index 00000000000..2ffef84827e --- /dev/null +++ b/tests/components/tami4/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tami4 integration.""" diff --git a/tests/components/tami4/conftest.py b/tests/components/tami4/conftest.py new file mode 100644 index 00000000000..2e8b4f4ffac --- /dev/null +++ b/tests/components/tami4/conftest.py @@ -0,0 +1,125 @@ +"""Common fixutres with default mocks as well as common test helper methods.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest +from Tami4EdgeAPI.device import Device +from Tami4EdgeAPI.water_quality import UV, Filter, WaterQuality + +from homeassistant.components.tami4.const import CONF_REFRESH_TOKEN, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def create_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create an entry in hass.""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="Device name", + data={CONF_REFRESH_TOKEN: "refresh_token"}, + ) + + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +@pytest.fixture +def mock_api(mock__get_devices, mock_get_water_quality): + """Fixture to mock all API calls.""" + + +@pytest.fixture +def mock__get_devices(request): + """Fixture to mock _get_devices which makes a call to the API.""" + + side_effect = getattr(request, "param", None) + + device = Device( + id=1, + name="Drink Water", + connected=True, + psn="psn", + type="type", + device_firmware="v1.1", + ) + + with patch( + "Tami4EdgeAPI.Tami4EdgeAPI.Tami4EdgeAPI._get_devices", + return_value=[device], + side_effect=side_effect, + ): + yield + + +@pytest.fixture +def mock_get_water_quality(request): + """Fixture to mock get_water_quality which makes a call to the API.""" + + side_effect = getattr(request, "param", None) + + water_quality = WaterQuality( + uv=UV( + last_replacement=int(datetime.now().timestamp()), + upcoming_replacement=int(datetime.now().timestamp()), + status="on", + ), + filter=Filter( + last_replacement=int(datetime.now().timestamp()), + upcoming_replacement=int(datetime.now().timestamp()), + status="on", + milli_litters_passed=1000, + ), + ) + + with patch( + "Tami4EdgeAPI.Tami4EdgeAPI.Tami4EdgeAPI.get_water_quality", + return_value=water_quality, + side_effect=side_effect, + ): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + + with patch( + "homeassistant.components.tami4.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_request_otp(request): + """Mock request_otp.""" + + side_effect = getattr(request, "param", None) + + with patch( + "homeassistant.components.tami4.config_flow.Tami4EdgeAPI.request_otp", + return_value=None, + side_effect=side_effect, + ) as mock_request_otp: + yield mock_request_otp + + +@pytest.fixture +def mock_submit_otp(request): + """Mock submit_otp.""" + + side_effect = getattr(request, "param", None) + + with patch( + "homeassistant.components.tami4.config_flow.Tami4EdgeAPI.submit_otp", + return_value="refresh_token", + side_effect=side_effect, + ) as mock_submit_otp: + yield mock_submit_otp diff --git a/tests/components/tami4/test_config_flow.py b/tests/components/tami4/test_config_flow.py new file mode 100644 index 00000000000..341e56bec84 --- /dev/null +++ b/tests/components/tami4/test_config_flow.py @@ -0,0 +1,163 @@ +"""Tests for the Tami4 config flow.""" + +import pytest +from Tami4EdgeAPI import exceptions + +from homeassistant import config_entries +from homeassistant.components.tami4.const import CONF_PHONE, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_step_user_valid_number( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock__get_devices, +) -> None: + """Test user step with valid phone number.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+972555555555"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + +async def test_step_user_invalid_number( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock__get_devices, +) -> None: + """Test user step with invalid phone number.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+275123"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_phone"} + + +@pytest.mark.parametrize( + ("mock_request_otp", "expected_error"), + [(Exception, "unknown"), (exceptions.OTPFailedException, "cannot_connect")], + indirect=["mock_request_otp"], +) +async def test_step_user_exception( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock__get_devices, + expected_error, +) -> None: + """Test user step with exception.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+972555555555"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + +async def test_step_otp_valid( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock_submit_otp, + mock__get_devices, +) -> None: + """Test user step with valid phone number.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+972555555555"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"otp": "123456"}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Drink Water" + assert "refresh_token" in result["data"] + + +@pytest.mark.parametrize( + ("mock_submit_otp", "expected_error"), + [ + (Exception, "unknown"), + (exceptions.Tami4EdgeAPIException, "cannot_connect"), + (exceptions.OTPFailedException, "invalid_auth"), + ], + indirect=["mock_submit_otp"], +) +async def test_step_otp_exception( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock_submit_otp, + mock__get_devices, + expected_error, +) -> None: + """Test user step with valid phone number.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+972555555555"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"otp": "123456"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {"base": expected_error} diff --git a/tests/components/tami4/test_init.py b/tests/components/tami4/test_init.py new file mode 100644 index 00000000000..ad3f50a377e --- /dev/null +++ b/tests/components/tami4/test_init.py @@ -0,0 +1,59 @@ +"""Test the Tami4 component.""" +import pytest +from Tami4EdgeAPI import exceptions + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import create_config_entry + + +async def test_init_success(mock_api, hass: HomeAssistant) -> None: + """Test setup and that we can create the entry.""" + + entry = await create_config_entry(hass) + assert entry.state == ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "mock_get_water_quality", [exceptions.APIRequestFailedException], indirect=True +) +async def test_init_with_api_error(mock_api, hass: HomeAssistant) -> None: + """Test init with api error.""" + + entry = await create_config_entry(hass) + assert entry.state == ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("mock__get_devices", "expected_state"), + [ + ( + exceptions.RefreshTokenExpiredException, + ConfigEntryState.SETUP_ERROR, + ), + ( + exceptions.TokenRefreshFailedException, + ConfigEntryState.SETUP_RETRY, + ), + ], + indirect=["mock__get_devices"], +) +async def test_init_error_raised( + mock_api, hass: HomeAssistant, expected_state: ConfigEntryState +) -> None: + """Test init when an error is raised.""" + + entry = await create_config_entry(hass) + assert entry.state == expected_state + + +async def test_load_unload(mock_api, hass: HomeAssistant) -> None: + """Config entry can be unloaded.""" + + entry = await create_config_entry(hass) + + 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/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 82fa89c5280..27b7bd1a82a 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -351,7 +351,7 @@ async def test_controlling_state_via_mqtt_on_off( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -361,7 +361,7 @@ async def test_controlling_state_via_mqtt_on_off( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') @@ -373,7 +373,7 @@ async def test_controlling_state_via_mqtt_on_off( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async def test_controlling_state_via_mqtt_ct( @@ -402,7 +402,7 @@ async def test_controlling_state_via_mqtt_ct( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -412,7 +412,7 @@ async def test_controlling_state_via_mqtt_ct( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' @@ -467,7 +467,7 @@ async def test_controlling_state_via_mqtt_rgbw( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -477,7 +477,7 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"White":0}' @@ -568,7 +568,7 @@ async def test_controlling_state_via_mqtt_rgbww( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -578,7 +578,7 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' @@ -604,7 +604,7 @@ async def test_controlling_state_via_mqtt_rgbww( state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white > 0 should clear the color - assert "rgb_color" not in state.attributes + assert not state.attributes.get("hs_color") assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message( @@ -621,7 +621,7 @@ async def test_controlling_state_via_mqtt_rgbww( state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white to 0 should clear the color_temp - assert "color_temp" not in state.attributes + assert not state.attributes.get("color_temp") assert state.attributes.get("hs_color") == (30, 100) assert state.attributes.get("color_mode") == "hs" @@ -670,7 +670,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -680,7 +680,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' @@ -716,7 +716,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white > 0 should clear the color - assert "rgb_color" not in state.attributes + assert not state.attributes.get("hs_color") assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message( diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index d8d445fbb86..af23efc1afc 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -65,6 +65,23 @@ def mock_register_webhook(): yield +@pytest.fixture +def mock_generate_secret_token(): + """Mock secret token generated for webhook.""" + mock_secret_token = "DEADBEEF12345678DEADBEEF87654321" + with patch( + "homeassistant.components.telegram_bot.webhooks.secrets.choice", + side_effect=mock_secret_token, + ): + yield mock_secret_token + + +@pytest.fixture +def incorrect_secret_token(): + """Mock incorrect secret token.""" + return "AAAABBBBCCCCDDDDEEEEFFFF00009999" + + @pytest.fixture def update_message_command(): """Fixture for mocking an incoming update of type message/command.""" @@ -156,7 +173,9 @@ def update_callback_query(): @pytest.fixture -async def webhook_platform(hass, config_webhooks, mock_register_webhook): +async def webhook_platform( + hass, config_webhooks, mock_register_webhook, mock_generate_secret_token +): """Fixture for setting up the webhooks platform using appropriate config and mocks.""" await async_setup_component( hass, diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index b87f15b3ed3..be28f7be636 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -35,12 +35,17 @@ async def test_webhook_endpoint_generates_telegram_text_event( webhook_platform, hass_client: ClientSessionGenerator, update_message_text, + mock_generate_secret_token, ) -> None: """POST to the configured webhook endpoint and assert fired `telegram_text` event.""" client = await hass_client() events = async_capture_events(hass, "telegram_text") - response = await client.post(TELEGRAM_WEBHOOK_URL, json=update_message_text) + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_message_text, + headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, + ) assert response.status == 200 assert (await response.read()).decode("utf-8") == "" @@ -56,12 +61,17 @@ async def test_webhook_endpoint_generates_telegram_command_event( webhook_platform, hass_client: ClientSessionGenerator, update_message_command, + mock_generate_secret_token, ) -> None: """POST to the configured webhook endpoint and assert fired `telegram_command` event.""" client = await hass_client() events = async_capture_events(hass, "telegram_command") - response = await client.post(TELEGRAM_WEBHOOK_URL, json=update_message_command) + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_message_command, + headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, + ) assert response.status == 200 assert (await response.read()).decode("utf-8") == "" @@ -77,12 +87,17 @@ async def test_webhook_endpoint_generates_telegram_callback_event( webhook_platform, hass_client: ClientSessionGenerator, update_callback_query, + mock_generate_secret_token, ) -> None: """POST to the configured webhook endpoint and assert fired `telegram_callback` event.""" client = await hass_client() events = async_capture_events(hass, "telegram_callback") - response = await client.post(TELEGRAM_WEBHOOK_URL, json=update_callback_query) + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_callback_query, + headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, + ) assert response.status == 200 assert (await response.read()).decode("utf-8") == "" @@ -119,13 +134,16 @@ async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_tex webhook_platform, hass_client: ClientSessionGenerator, unauthorized_update_message_text, + mock_generate_secret_token, ) -> None: """Update with unauthorized user/chat should not trigger event.""" client = await hass_client() events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, json=unauthorized_update_message_text + TELEGRAM_WEBHOOK_URL, + json=unauthorized_update_message_text, + headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) assert response.status == 200 assert (await response.read()).decode("utf-8") == "" @@ -134,3 +152,39 @@ async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_tex await hass.async_block_till_done() assert len(events) == 0 + + +async def test_webhook_endpoint_without_secret_token_is_denied( + hass: HomeAssistant, + webhook_platform, + hass_client: ClientSessionGenerator, + update_message_text, +) -> None: + """Request without a secret token header should be denied.""" + client = await hass_client() + async_capture_events(hass, "telegram_text") + + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_message_text, + ) + assert response.status == 401 + + +async def test_webhook_endpoint_invalid_secret_token_is_denied( + hass: HomeAssistant, + webhook_platform, + hass_client: ClientSessionGenerator, + update_message_text, + incorrect_secret_token, +) -> None: + """Request with an invalid secret token header should be denied.""" + client = await hass_client() + async_capture_events(hass, "telegram_text") + + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_message_text, + headers={"X-Telegram-Bot-Api-Secret-Token": incorrect_secret_token}, + ) + assert response.status == 401 diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr new file mode 100644 index 00000000000..72af2ab1637 --- /dev/null +++ b/tests/components/template/snapshots/test_weather.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_forecasts[config0-1-weather] + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'fog', + 'datetime': '2023-02-17T14:00:00+00:00', + 'is_daytime': True, + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather].3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 16.9, + }), + ]), + }) +# --- +# name: test_restore_weather_save_state + dict({ + 'last_apparent_temperature': None, + 'last_cloud_coverage': None, + 'last_dew_point': None, + 'last_humidity': '25.0', + 'last_ozone': None, + 'last_pressure': None, + 'last_temperature': '15.0', + 'last_visibility': None, + 'last_wind_bearing': None, + 'last_wind_gust_speed': None, + 'last_wind_speed': None, + }) +# --- +# name: test_trigger_weather_services[config0-1-template] + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'is_daytime': True, + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 111580647f5..f807b185c45 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -130,7 +130,7 @@ async def test_template_state_invalid( """Test template state with render error.""" state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == supported_color_modes assert state.attributes["supported_features"] == supported_features @@ -163,7 +163,7 @@ async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: await hass.async_block_till_done() state = hass.states.get("light.test_template_light") assert state.state == set_state - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -281,7 +281,7 @@ async def test_on_action(hass: HomeAssistant, setup_light, calls) -> None: state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -297,7 +297,7 @@ async def test_on_action(hass: HomeAssistant, setup_light, calls) -> None: assert calls[-1].data["caller"] == "light.test_template_light" assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -341,7 +341,7 @@ async def test_on_action_with_transition( state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == LightEntityFeature.TRANSITION @@ -356,7 +356,7 @@ async def test_on_action_with_transition( assert calls[0].data["transition"] == 5 assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == LightEntityFeature.TRANSITION @@ -383,7 +383,7 @@ async def test_on_action_optimistic( state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -533,7 +533,7 @@ async def test_off_action_optimistic(hass: HomeAssistant, setup_light, calls) -> """Test off action with optimistic state.""" state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -547,7 +547,7 @@ async def test_off_action_optimistic(hass: HomeAssistant, setup_light, calls) -> assert len(calls) == 1 state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -921,7 +921,7 @@ async def test_color_and_temperature_actions_no_template( state = hass.states.get("light.test_template_light") assert state.attributes["color_mode"] == ColorMode.HS - assert "color_temp" not in state.attributes + assert state.attributes["color_temp"] is None assert state.attributes["hs_color"] == (40, 50) assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, @@ -964,7 +964,7 @@ async def test_color_and_temperature_actions_no_template( state = hass.states.get("light.test_template_light") assert state.attributes["color_mode"] == ColorMode.HS - assert "color_temp" not in state.attributes + assert state.attributes["color_temp"] is None assert state.attributes["hs_color"] == (10, 20) assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 7ca3d11b099..524f9c41aeb 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -2,6 +2,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.weather import ( ATTR_FORECAST, @@ -112,7 +113,9 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_forecasts(hass: HomeAssistant, start_ha) -> None: +async def test_forecasts( + hass: HomeAssistant, start_ha, snapshot: SnapshotAssertion +) -> None: """Test forecast service.""" for attr, _v_attr, value in [ ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), @@ -163,15 +166,7 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "condition": "cloudy", - "datetime": "2023-02-17T14:00:00+00:00", - "temperature": 14.2, - } - ] - } + assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, SERVICE_GET_FORECAST, @@ -179,15 +174,7 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "condition": "cloudy", - "datetime": "2023-02-17T14:00:00+00:00", - "temperature": 14.2, - } - ] - } + assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, SERVICE_GET_FORECAST, @@ -195,16 +182,7 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "condition": "fog", - "datetime": "2023-02-17T14:00:00+00:00", - "temperature": 14.2, - "is_daytime": True, - } - ] - } + assert response == snapshot hass.states.async_set( "weather.forecast", @@ -231,15 +209,7 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "condition": "cloudy", - "datetime": "2023-02-17T14:00:00+00:00", - "temperature": 16.9, - } - ] - } + assert response == snapshot @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @@ -263,7 +233,9 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: ], ) async def test_forecast_invalid( - hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + start_ha, + caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid forecasts.""" for attr, _v_attr, value in [ @@ -336,7 +308,9 @@ async def test_forecast_invalid( ], ) async def test_forecast_invalid_is_daytime_missing_in_twice_daily( - hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + start_ha, + caplog: pytest.LogCaptureFixture, ) -> None: """Test forecast service invalid when is_daytime missing in twice_daily forecast.""" for attr, _v_attr, value in [ @@ -395,7 +369,9 @@ async def test_forecast_invalid_is_daytime_missing_in_twice_daily( ], ) async def test_forecast_invalid_datetime_missing( - hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + start_ha, + caplog: pytest.LogCaptureFixture, ) -> None: """Test forecast service invalid when datetime missing.""" for attr, _v_attr, value in [ @@ -712,8 +688,12 @@ async def test_trigger_action( }, ], ) +@pytest.mark.freeze_time("2023-10-19 13:50:05") async def test_trigger_weather_services( - hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry + hass: HomeAssistant, + start_ha, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test trigger weather entity with services.""" state = hass.states.get("weather.test") @@ -784,17 +764,7 @@ async def test_trigger_weather_services( blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "datetime": now, - "condition": "sunny", - "precipitation": 20.0, - "temperature": 20.0, - "templow": 15.0, - } - ], - } + assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, @@ -806,17 +776,7 @@ async def test_trigger_weather_services( blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "datetime": now, - "condition": "sunny", - "precipitation": 20.0, - "temperature": 20.0, - "templow": 15.0, - } - ], - } + assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, @@ -828,23 +788,11 @@ async def test_trigger_weather_services( blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "datetime": now, - "condition": "sunny", - "precipitation": 20.0, - "temperature": 20.0, - "templow": 15.0, - "is_daytime": True, - } - ], - } + assert response == snapshot async def test_restore_weather_save_state( - hass: HomeAssistant, - hass_storage: dict[str, Any], + hass: HomeAssistant, hass_storage: dict[str, Any], snapshot: SnapshotAssertion ) -> None: """Test Restore saved state for Weather trigger template.""" assert await async_setup_component( @@ -881,19 +829,7 @@ async def test_restore_weather_save_state( state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] assert state["entity_id"] == entity.entity_id extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] - assert extra_data == { - "last_apparent_temperature": None, - "last_cloud_coverage": None, - "last_dew_point": None, - "last_humidity": "25.0", - "last_ozone": None, - "last_pressure": None, - "last_temperature": "15.0", - "last_visibility": None, - "last_wind_bearing": None, - "last_wind_gust_speed": None, - "last_wind_speed": None, - } + assert extra_data == snapshot SAVED_ATTRIBUTES_1 = { diff --git a/tests/components/text/test_device_action.py b/tests/components/text/test_device_action.py index 88bf692b711..59b77ecfa06 100644 --- a/tests/components/text/test_device_action.py +++ b/tests/components/text/test_device_action.py @@ -137,9 +137,21 @@ async def test_get_action_no_state( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) assert await async_setup_component( @@ -154,7 +166,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_value", "value": 0.3, @@ -175,10 +187,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) assert await async_setup_component( @@ -193,7 +215,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_value", "value": 0.3, diff --git a/tests/components/todo/__init__.py b/tests/components/todo/__init__.py new file mode 100644 index 00000000000..dfee74599cd --- /dev/null +++ b/tests/components/todo/__init__.py @@ -0,0 +1 @@ +"""Tests for the To-do integration.""" diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py new file mode 100644 index 00000000000..3e84049efa8 --- /dev/null +++ b/tests/components/todo/test_init.py @@ -0,0 +1,739 @@ +"""Tests for the todo integration.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components.todo import ( + DOMAIN, + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) +from tests.typing import WebSocketGenerator + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[TodoListEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="test_entity") +def mock_test_entity() -> TodoListEntity: + """Fixture that creates a test TodoList entity with mock service calls.""" + entity1 = TodoListEntity() + entity1.entity_id = "todo.entity1" + entity1._attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + ) + entity1._attr_todo_items = [ + TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), + ] + entity1.async_create_todo_item = AsyncMock() + entity1.async_update_todo_item = AsyncMock() + entity1.async_delete_todo_items = AsyncMock() + entity1.async_move_todo_item = AsyncMock() + return entity1 + + +async def test_unload_entry( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test unloading a config entry with a todo entity.""" + + config_entry = await create_mock_platform(hass, [test_entity]) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get("todo.entity1") + assert state + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + state = hass.states.get("todo.entity1") + assert not state + + +async def test_list_todo_items( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, +) -> None: + """Test listing items in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + state = hass.states.get("todo.entity1") + assert state + assert state.state == "1" + assert state.attributes == {"supported_features": 15} + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 1, "type": "todo/item/list", "entity_id": "todo.entity1"} + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") + assert resp.get("result") == { + "items": [ + {"summary": "Item #1", "uid": "1", "status": "needs_action"}, + {"summary": "Item #2", "uid": "2", "status": "completed"}, + ] + } + + +async def test_unsupported_websocket( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a To-do list that does not support features.""" + + entity1 = TodoListEntity() + entity1.entity_id = "todo.entity1" + await create_mock_platform(hass, [entity1]) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "todo/item/list", + "entity_id": "todo.unknown", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("error", {}).get("code") == "not_found" + + +async def test_add_item_service( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test adding an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "add_item", + {"item": "New item"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_create_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid is None + assert item.summary == "New item" + assert item.status == TodoItemStatus.NEEDS_ACTION + + +async def test_add_item_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test adding an item in a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + test_entity.async_create_todo_item.side_effect = HomeAssistantError("Ooops") + with pytest.raises(HomeAssistantError, match="Ooops"): + await hass.services.async_call( + DOMAIN, + "add_item", + {"item": "New item"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("item_data", "expected_error"), + [ + ({}, "required key not provided"), + ({"item": ""}, "length of value must be at least 1"), + ], +) +async def test_add_item_service_invalid_input( + hass: HomeAssistant, + test_entity: TodoListEntity, + item_data: dict[str, Any], + expected_error: str, +) -> None: + """Test invalid input to the add item service.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(vol.Invalid, match=expected_error): + await hass.services.async_call( + DOMAIN, + "add_item", + item_data, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_update_todo_item_service_by_id( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", "rename": "Updated item", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "1" + assert item.summary == "Updated item" + assert item.status == TodoItemStatus.COMPLETED + + +async def test_update_todo_item_service_by_id_status_only( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "1" + assert item.summary is None + assert item.status == TodoItemStatus.COMPLETED + + +async def test_update_todo_item_service_by_id_rename( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", "rename": "Updated item"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "1" + assert item.summary == "Updated item" + assert item.status is None + + +async def test_update_todo_item_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", "rename": "Updated item", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + test_entity.async_update_todo_item.side_effect = HomeAssistantError("Ooops") + with pytest.raises(HomeAssistantError, match="Ooops"): + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", "rename": "Updated item", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_update_todo_item_service_by_summary( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list by summary.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "Item #1", "rename": "Something else", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "1" + assert item.summary == "Something else" + assert item.status == TodoItemStatus.COMPLETED + + +async def test_update_todo_item_service_by_summary_only_status( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list by summary.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "Item #1", "rename": "Something else"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "1" + assert item.summary == "Something else" + assert item.status is None + + +async def test_update_todo_item_service_by_summary_not_found( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list by summary which is not found.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(ValueError, match="Unable to find"): + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "Item #7", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("item_data", "expected_error"), + [ + ({}, r"required key not provided @ data\['item'\]"), + ({"status": "needs_action"}, r"required key not provided @ data\['item'\]"), + ({"item": "Item #1"}, "must contain at least one of"), + ( + {"item": "", "status": "needs_action"}, + "length of value must be at least 1", + ), + ], +) +async def test_update_item_service_invalid_input( + hass: HomeAssistant, + test_entity: TodoListEntity, + item_data: dict[str, Any], + expected_error: str, +) -> None: + """Test invalid input to the update item service.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(vol.Invalid, match=expected_error): + await hass.services.async_call( + DOMAIN, + "update_item", + item_data, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_remove_todo_item_service_by_id( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test removing an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "remove_item", + {"item": ["1", "2"]}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_delete_todo_items.call_args + assert args + assert args.kwargs.get("uids") == ["1", "2"] + + +async def test_remove_todo_item_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test removing an item in a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + test_entity.async_delete_todo_items.side_effect = HomeAssistantError("Ooops") + with pytest.raises(HomeAssistantError, match="Ooops"): + await hass.services.async_call( + DOMAIN, + "remove_item", + {"item": ["1", "2"]}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_remove_todo_item_service_invalid_input( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test invalid input to the remove item service.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises( + vol.Invalid, match=r"required key not provided @ data\['item'\]" + ): + await hass.services.async_call( + DOMAIN, + "remove_item", + {}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_remove_todo_item_service_by_summary( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test removing an item in a To-do list by summary.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "remove_item", + {"item": ["Item #1"]}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_delete_todo_items.call_args + assert args + assert args.kwargs.get("uids") == ["1"] + + +async def test_remove_todo_item_service_by_summary_not_found( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test removing an item in a To-do list by summary which is not found.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(ValueError, match="Unable to find"): + await hass.services.async_call( + DOMAIN, + "remove_item", + {"item": ["Item #7"]}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_move_todo_item_service_by_id( + hass: HomeAssistant, + test_entity: TodoListEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test moving an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.entity1", + "uid": "item-1", + "previous_uid": "item-2", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") + + args = test_entity.async_move_todo_item.call_args + assert args + assert args.kwargs.get("uid") == "item-1" + assert args.kwargs.get("previous_uid") == "item-2" + + +async def test_move_todo_item_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test moving an item in a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + test_entity.async_move_todo_item.side_effect = HomeAssistantError("Ooops") + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.entity1", + "uid": "item-1", + "previous_uid": "item-2", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("error", {}).get("code") == "failed" + assert resp.get("error", {}).get("message") == "Ooops" + + +@pytest.mark.parametrize( + ("item_data", "expected_status", "expected_error"), + [ + ( + {"entity_id": "todo.unknown", "uid": "item-1"}, + "not_found", + "Entity not found", + ), + ({"entity_id": "todo.entity1"}, "invalid_format", "required key not provided"), + ( + {"entity_id": "todo.entity1", "previous_uid": "item-2"}, + "invalid_format", + "required key not provided", + ), + ], +) +async def test_move_todo_item_service_invalid_input( + hass: HomeAssistant, + test_entity: TodoListEntity, + hass_ws_client: WebSocketGenerator, + item_data: dict[str, Any], + expected_status: str, + expected_error: str, +) -> None: + """Test invalid input for the move item service.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + **item_data, + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("error", {}).get("code") == expected_status + assert expected_error in resp.get("error", {}).get("message") + + +@pytest.mark.parametrize( + ("service_name", "payload"), + [ + ( + "add_item", + { + "item": "New item", + }, + ), + ( + "remove_item", + { + "item": ["1"], + }, + ), + ( + "update_item", + { + "item": "1", + "rename": "Updated item", + }, + ), + ], +) +async def test_unsupported_service( + hass: HomeAssistant, + service_name: str, + payload: dict[str, Any], +) -> None: + """Test a To-do list that does not support features.""" + + entity1 = TodoListEntity() + entity1.entity_id = "todo.entity1" + await create_mock_platform(hass, [entity1]) + + with pytest.raises( + HomeAssistantError, + match="does not support this service", + ): + await hass.services.async_call( + DOMAIN, + service_name, + payload, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_move_item_unsupported( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test invalid input for the move item service.""" + + entity1 = TodoListEntity() + entity1.entity_id = "todo.entity1" + await create_mock_platform(hass, [entity1]) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.entity1", + "uid": "item-1", + "previous_uid": "item-2", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("error", {}).get("code") == "not_supported" diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 6543e5b678f..28f22e1061a 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -9,15 +9,17 @@ from requests.models import Response from todoist_api_python.models import Collaborator, Due, Label, Project, Task from homeassistant.components.todoist import DOMAIN -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +PROJECT_ID = "project-id-1" SUMMARY = "A task" TOKEN = "some-token" +TODAY = dt_util.now().strftime("%Y-%m-%d") @pytest.fixture @@ -37,38 +39,49 @@ def mock_due() -> Due: ) -@pytest.fixture(name="task") -def mock_task(due: Due) -> Task: +def make_api_task( + id: str | None = None, + content: str | None = None, + is_completed: bool = False, + due: Due | None = None, + project_id: str | None = None, +) -> Task: """Mock a todoist Task instance.""" return Task( assignee_id="1", assigner_id="1", comment_count=0, - is_completed=False, - content=SUMMARY, + is_completed=is_completed, + content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=due, - id="1", + due=due or Due(is_recurring=False, date=TODAY, string="today"), + id=id or "1", labels=["Label1"], order=1, parent_id=None, priority=1, - project_id="12345", + project_id=project_id or PROJECT_ID, section_id=None, url="https://todoist.com", sync_id=None, ) +@pytest.fixture(name="tasks") +def mock_tasks(due: Due) -> list[Task]: + """Mock a todoist Task instance.""" + return [make_api_task(due=due)] + + @pytest.fixture(name="api") -def mock_api(task) -> AsyncMock: +def mock_api(tasks: list[Task]) -> AsyncMock: """Mock the api state.""" api = AsyncMock() api.get_projects.return_value = [ Project( - id="12345", + id=PROJECT_ID, color="blue", comment_count=0, is_favorite=False, @@ -88,7 +101,7 @@ def mock_api(task) -> AsyncMock: api.get_collaborators.return_value = [ Collaborator(email="user@gmail.com", id="1", name="user") ] - api.get_tasks.return_value = [task] + api.get_tasks.return_value = tasks return api @@ -121,15 +134,25 @@ def mock_todoist_domain() -> str: return DOMAIN +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, + platforms: list[Platform], api: AsyncMock, todoist_config_entry: MockConfigEntry | None, ) -> None: """Mock setup of the todoist integration.""" if todoist_config_entry is not None: todoist_config_entry.add_to_hass(hass) - with patch("homeassistant.components.todoist.TodoistAPIAsync", return_value=api): + with patch( + "homeassistant.components.todoist.TodoistAPIAsync", return_value=api + ), patch("homeassistant.components.todoist.PLATFORMS", platforms): assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() yield diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 45300e2e66c..761eeb07c61 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -18,13 +18,13 @@ from homeassistant.components.todoist.const import ( PROJECT_NAME, SERVICE_NEW_TASK, ) -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import dt as dt_util -from .conftest import SUMMARY +from .conftest import PROJECT_ID, SUMMARY from tests.typing import ClientSessionGenerator @@ -34,6 +34,12 @@ TZ_NAME = "America/Regina" TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Override platforms.""" + return [Platform.CALENDAR] + + @pytest.fixture(autouse=True) def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" @@ -97,7 +103,7 @@ async def test_calendar_entity_unique_id( ) -> None: """Test unique id is set to project id.""" entity = entity_registry.async_get("calendar.name") - assert entity.unique_id == "12345" + assert entity.unique_id == PROJECT_ID @pytest.mark.parametrize( @@ -256,7 +262,7 @@ async def test_create_task_service_call(hass: HomeAssistant, api: AsyncMock) -> await hass.async_block_till_done() api.add_task.assert_called_with( - "task", project_id="12345", labels=["Label1"], assignee_id="1" + "task", project_id=PROJECT_ID, labels=["Label1"], assignee_id="1" ) diff --git a/tests/components/todoist/test_config_flow.py b/tests/components/todoist/test_config_flow.py index 4175902da31..141f12269de 100644 --- a/tests/components/todoist/test_config_flow.py +++ b/tests/components/todoist/test_config_flow.py @@ -69,7 +69,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("errors") == {"base": "invalid_access_token"} + assert result2.get("errors") == {"base": "invalid_api_key"} @pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) diff --git a/tests/components/todoist/test_init.py b/tests/components/todoist/test_init.py index cc64464df1d..0e80be5410f 100644 --- a/tests/components/todoist/test_init.py +++ b/tests/components/todoist/test_init.py @@ -1,7 +1,6 @@ """Unit tests for the Todoist integration.""" -from collections.abc import Generator from http import HTTPStatus -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest @@ -12,15 +11,6 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -@pytest.fixture(autouse=True) -def mock_platforms() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.todoist.PLATFORMS", return_value=[] - ) as mock_setup_entry: - yield mock_setup_entry - - async def test_load_unload( hass: HomeAssistant, setup_integration: None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py new file mode 100644 index 00000000000..a14f362ea5b --- /dev/null +++ b/tests/components/todoist/test_todo.py @@ -0,0 +1,232 @@ +"""Unit tests for the Todoist todo platform.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity + +from .conftest import PROJECT_ID, make_api_task + + +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.TODO] + + +@pytest.mark.parametrize( + ("tasks", "expected_state"), + [ + ([], "0"), + ([make_api_task(id="12345", content="Soda", is_completed=False)], "1"), + ([make_api_task(id="12345", content="Soda", is_completed=True)], "0"), + ( + [ + make_api_task(id="12345", content="Milk", is_completed=False), + make_api_task(id="54321", content="Soda", is_completed=False), + ], + "2", + ), + ( + [ + make_api_task( + id="12345", + content="Soda", + is_completed=False, + project_id="other-project-id", + ) + ], + "0", + ), + ], +) +async def test_todo_item_state( + hass: HomeAssistant, + setup_integration: None, + expected_state: str, +) -> None: + """Test for a To-do List entity state.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == expected_state + + +@pytest.mark.parametrize(("tasks"), [[]]) +async def test_add_todo_list_item( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for adding a To-do Item.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + api.add_task = AsyncMock() + # Fake API response when state is refreshed after create + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=False) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Soda"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + args = api.add_task.call_args + assert args + assert args.kwargs.get("content") == "Soda" + assert args.kwargs.get("project_id") == PROJECT_ID + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] +) +async def test_update_todo_item_status( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for updating a To-do Item that changes the status.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + api.close_task = AsyncMock() + api.reopen_task = AsyncMock() + + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=True) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "task-id-1", "status": "completed"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.close_task.called + args = api.close_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + assert not api.reopen_task.called + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + # Fake API response when state is refreshed after reopen + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=False) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "task-id-1", "status": "needs_action"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.reopen_task.called + args = api.reopen_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] +) +async def test_update_todo_item_summary( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for updating a To-do Item that changes the summary.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + api.update_task = AsyncMock() + + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=True) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "task-id-1", "rename": "Milk"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.update_task.called + args = api.update_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + assert args.kwargs.get("content") == "Milk" + + +@pytest.mark.parametrize( + ("tasks"), + [ + [ + make_api_task(id="task-id-1", content="Soda", is_completed=False), + make_api_task(id="task-id-2", content="Milk", is_completed=False), + ] + ], +) +async def test_remove_todo_item( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for removing a To-do Item.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "2" + + api.delete_task = AsyncMock() + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [] + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["task-id-1", "task-id-2"]}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.delete_task.call_count == 2 + args = api.delete_task.call_args_list + assert args[0].kwargs.get("task_id") == "task-id-1" + assert args[1].kwargs.get("task_id") == "task-id-2" + + await async_update_entity(hass, "todo.name") + state = hass.states.get("todo.name") + assert state + assert state.state == "0" diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 77335769383..53e455ffb8d 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -37,8 +37,8 @@ O3 = "ozone" CO = "carbon_monoxide" NO2 = "nitrogen_dioxide" SO2 = "sulphur_dioxide" -PM25 = "particulate_matter_2_5_mm" -PM10 = "particulate_matter_10_mm" +PM25 = "pm2_5" +PM10 = "pm10" MEP_AQI = "china_mep_air_quality_index" MEP_HEALTH_CONCERN = "china_mep_health_concern" MEP_PRIMARY_POLLUTANT = "china_mep_primary_pollutant" @@ -51,10 +51,10 @@ WEED_POLLEN = "weed_pollen_index" TREE_POLLEN = "tree_pollen_index" FEELS_LIKE = "feels_like" DEW_POINT = "dew_point" -PRESSURE_SURFACE_LEVEL = "pressure_surface_level" +PRESSURE_SURFACE_LEVEL = "pressure" SNOW_ACCUMULATION = "snow_accumulation" ICE_ACCUMULATION = "ice_accumulation" -GHI = "global_horizontal_irradiance" +GHI = "irradiance" CLOUD_BASE = "cloud_base" CLOUD_COVER = "cloud_cover" CLOUD_CEILING = "cloud_ceiling" @@ -121,6 +121,7 @@ async def _setup( data = _get_config_schema(hass, SOURCE_USER)(config) data[CONF_NAME] = DEFAULT_NAME config_entry = MockConfigEntry( + title=DEFAULT_NAME, domain=DOMAIN, data=data, options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 229e62065a6..863623ee524 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -78,6 +78,7 @@ async def _setup_config_entry(hass: HomeAssistant, config: dict[str, Any]) -> St data = _get_config_schema(hass, SOURCE_USER)(config) data[CONF_NAME] = DEFAULT_NAME config_entry = MockConfigEntry( + title=DEFAULT_NAME, domain=DOMAIN, data=data, options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, @@ -228,7 +229,7 @@ async def test_v4_weather(hass: HomeAssistant, tomorrowio_config_entry_update) - ATTR_FORECAST_WIND_BEARING: 239.6, ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } - assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" @@ -261,7 +262,7 @@ async def test_v4_weather_legacy_entities(hass: HomeAssistant) -> None: ATTR_FORECAST_WIND_BEARING: 239.6, ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } - assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 4206c0de6ad..c40560d2a89 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -29,7 +29,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_configuring_tplink_causes_discovery(hass: HomeAssistant) -> None: """Test that specifying empty config does discovery.""" - with patch("homeassistant.components.tplink.Discover.discover") as discover: + with patch("homeassistant.components.tplink.Discover.discover") as discover, patch( + "homeassistant.components.tplink.Discover.discover_single" + ): discover.return_value = {MagicMock(): MagicMock()} await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index cd8494e9b98..348fcc50ce0 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -442,7 +442,7 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ON - assert ATTR_EFFECT not in state.attributes + assert state.attributes[ATTR_EFFECT] is None strip.is_off = True strip.is_on = False @@ -451,7 +451,7 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert ATTR_EFFECT not in state.attributes + assert state.attributes[ATTR_EFFECT] is None await hass.services.async_call( LIGHT_DOMAIN, @@ -574,7 +574,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert ATTR_EFFECT not in state.attributes + assert state.attributes[ATTR_EFFECT] is None await hass.services.async_call( LIGHT_DOMAIN, diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr index b48f6a5e749..99f49e44bf2 100644 --- a/tests/components/tplink_omada/snapshots/test_switch.ambr +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -36,6 +36,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 1 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000001_poe', @@ -79,6 +80,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 6 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000006_poe', @@ -122,6 +124,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 7 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000007_poe', @@ -165,6 +168,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 8 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000008_poe', @@ -208,6 +212,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 2 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000002_poe', @@ -251,6 +256,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 3 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000003_poe', @@ -294,6 +300,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 4 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000004_poe', @@ -337,6 +344,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 5 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000005_poe', diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index 95c145bbeb3..a4902ac2950 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -32,7 +32,8 @@ async def load_integration_from_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index ae3410d20b3..b53763c0ac7 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -53,7 +53,7 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: "location": "Test location", } assert len(mock_setup_entry.mock_calls) == 1 - assert result2["result"].unique_id == "trafikverket_camera-Test location" + assert result2["result"].unique_id == "trafikverket_camera-1234" async def test_form_no_location_data( @@ -90,7 +90,7 @@ async def test_form_no_location_data( "location": "Test Camera", } assert len(mock_setup_entry.mock_calls) == 1 - assert result2["result"].unique_id == "trafikverket_camera-Test Camera" + assert result2["result"].unique_id == "trafikverket_camera-1234" @pytest.mark.parametrize( @@ -153,6 +153,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: CONF_LOCATION: "Test location", }, unique_id="1234", + version=2, ) entry.add_to_hass(hass) @@ -225,6 +226,7 @@ async def test_reauth_flow_error( CONF_LOCATION: "Test location", }, unique_id="1234", + version=2, ) entry.add_to_hass(hass) await hass.async_block_till_done() diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py index 2b21ce935b2..4183aa9fffa 100644 --- a/tests/components/trafikverket_camera/test_coordinator.py +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -40,7 +40,8 @@ async def test_coordinator( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) @@ -100,7 +101,8 @@ async def test_coordinator_failed_update( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) @@ -133,7 +135,8 @@ async def test_coordinator_failed_get_image( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py index d9de0a830a6..83a3fc1486a 100644 --- a/tests/components/trafikverket_camera/test_init.py +++ b/tests/components/trafikverket_camera/test_init.py @@ -1,14 +1,18 @@ """Test for Trafikverket Ferry component Init.""" from __future__ import annotations +from datetime import datetime from unittest.mock import patch +from pytrafikverket.exceptions import UnknownError from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries +from homeassistant.components.trafikverket_camera import async_migrate_entry from homeassistant.components.trafikverket_camera.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -31,7 +35,8 @@ async def test_setup_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) @@ -62,7 +67,8 @@ async def test_unload_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="321", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) @@ -78,3 +84,145 @@ async def test_unload_entry( assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test migrate entry to version 2.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="trafikverket_camera-Test location", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ) as mock_tvt_camera: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.unique_id == "trafikverket_camera-1234" + assert len(mock_tvt_camera.mock_calls) == 2 + + +async def test_migrate_entry_fails_with_error( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test migrate entry fails with api error.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="trafikverket_camera-Test location", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + side_effect=UnknownError, + ) as mock_tvt_camera: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + assert entry.version == 1 + assert entry.unique_id == "trafikverket_camera-Test location" + assert len(mock_tvt_camera.mock_calls) == 1 + + +async def test_migrate_entry_fails_no_id( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test migrate entry fails, camera returns no id.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="trafikverket_camera-Test location", + title="Test location", + ) + entry.add_to_hass(hass) + + _camera = CameraInfo( + camera_name="Test_camera", + camera_id=None, + active=True, + deleted=False, + description="Test Camera for testing", + direction="180", + fullsizephoto=True, + location="Test location", + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo.jpg", + status="Running", + camera_type="Road", + ) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=_camera, + ) as mock_tvt_camera: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + assert entry.version == 1 + assert entry.unique_id == "trafikverket_camera-Test location" + assert len(mock_tvt_camera.mock_calls) == 1 + + +async def test_no_migration_needed( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test migrate entry fails, camera returns no id.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + version=2, + entry_id="1234", + unique_id="trafikverket_camera-1234", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + assert await async_migrate_entry(hass, entry) is True diff --git a/tests/components/transmission/__init__.py b/tests/components/transmission/__init__.py index 9da6c8304e0..e371a3691a2 100644 --- a/tests/components/transmission/__init__.py +++ b/tests/components/transmission/__init__.py @@ -1,9 +1,15 @@ """Tests for Transmission.""" -MOCK_CONFIG_DATA = { +OLD_MOCK_CONFIG_DATA = { "name": "Transmission", "host": "0.0.0.0", "username": "user", "password": "pass", "port": 9091, } +MOCK_CONFIG_DATA = { + "host": "0.0.0.0", + "username": "user", + "password": "pass", + "port": 9091, +} diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index b4fae8e6f3d..04f44d3b7e7 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -71,33 +71,12 @@ async def test_device_already_configured( assert result2["reason"] == "already_configured" -async def test_name_already_configured(hass: HomeAssistant) -> None: - """Test name is already configured.""" - entry = MockConfigEntry( - domain=transmission.DOMAIN, - data=MOCK_CONFIG_DATA, - options={"scan_interval": 120}, - ) - entry.add_to_hass(hass) - - mock_entry = MOCK_CONFIG_DATA.copy() - mock_entry["host"] = "1.1.1.1" - result = await hass.config_entries.flow.async_init( - transmission.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=mock_entry, - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"name": "name_exists"} - - async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" entry = MockConfigEntry( domain=transmission.DOMAIN, data=MOCK_CONFIG_DATA, - options={"scan_interval": 120}, + options={"limit": 10, "order": "oldest_first"}, ) entry.add_to_hass(hass) @@ -114,11 +93,12 @@ async def test_options(hass: HomeAssistant) -> None: assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"scan_interval": 10} + result["flow_id"], user_input={"limit": 20} ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"]["scan_interval"] == 10 + assert result["data"]["limit"] == 20 + assert result["data"]["order"] == "oldest_first" async def test_error_on_wrong_credentials( diff --git a/tests/components/transmission/test_init.py b/tests/components/transmission/test_init.py index 84bbf6be6ef..63b7ac154ed 100644 --- a/tests/components/transmission/test_init.py +++ b/tests/components/transmission/test_init.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MOCK_CONFIG_DATA +from . import MOCK_CONFIG_DATA, OLD_MOCK_CONFIG_DATA from tests.common import MockConfigEntry @@ -139,7 +139,7 @@ async def test_migrate_unique_id( new_unique_id: str, ) -> None: """Test unique id migration.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA, entry_id="1234") + entry = MockConfigEntry(domain=DOMAIN, data=OLD_MOCK_CONFIG_DATA, entry_id="1234") entry.add_to_hass(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index d004084e063..40b9f818f52 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -68,6 +68,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': None, 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345', @@ -96,6 +97,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 367da49c7f6..5c9a1e54098 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -37,6 +37,7 @@ 'original_icon': 'mdi:pine-tree', 'original_name': 'Christmas tree pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'christmas_tree_pickup', 'unique_id': 'twentemilieu_12345_tree', @@ -65,6 +66,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -108,6 +110,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': 'Non-recyclable waste pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'non_recyclable_waste_pickup', 'unique_id': 'twentemilieu_12345_Non-recyclable', @@ -136,6 +139,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -179,6 +183,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': 'Organic waste pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'organic_waste_pickup', 'unique_id': 'twentemilieu_12345_Organic', @@ -207,6 +212,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -250,6 +256,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': 'Packages waste pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'packages_waste_pickup', 'unique_id': 'twentemilieu_12345_Plastic', @@ -278,6 +285,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -321,6 +329,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': 'Paper waste pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'paper_waste_pickup', 'unique_id': 'twentemilieu_12345_Paper', @@ -349,6 +358,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index cda2ad3d60e..7a7dc2557ef 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -4,6 +4,7 @@ 'attributes': dict({ 'brightness': 26, 'color_mode': 'brightness', + 'effect': None, 'effect_list': list([ ]), 'friendly_name': 'twinkly_test_device_name', diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index ba906f86eef..71d2dc038c1 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, + CONF_CLIENT_SOURCE, CONF_DETECTION_TIME, CONF_DPI_RESTRICTIONS, CONF_IGNORE_WIRED_BUG, @@ -462,6 +463,17 @@ async def test_advanced_option_flow( config_entry.entry_id, context={"show_advanced_options": True} ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure_entity_sources" + assert not result["last_step"] + assert list(result["data_schema"].schema[CONF_CLIENT_SOURCE].options.keys()) == [ + "00:00:00:00:00:01" + ] + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_CLIENT_SOURCE: ["00:00:00:00:00:01"]}, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "device_tracker" assert not result["last_step"] @@ -510,6 +522,7 @@ async def test_advanced_option_flow( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"] == { + CONF_CLIENT_SOURCE: ["00:00:00:00:00:01"], CONF_TRACK_CLIENTS: False, CONF_TRACK_WIRED_CLIENTS: False, CONF_TRACK_DEVICES: False, diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 93b39d2fdf2..9d4bde2d016 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -167,6 +167,11 @@ def mock_default_unifi_requests( json={"data": wlans_response or [], "meta": {"rc": "ok"}}, headers={"content-type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"https://{host}:1234/v2/api/site/{site_id}/trafficrules", + json=[{}], + headers={"content-type": CONTENT_TYPE_JSON}, + ) async def setup_unifi_integration( diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 2680a357d77..cbff868d9a6 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -9,6 +9,7 @@ from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.unifi.const import ( CONF_BLOCK_CLIENT, + CONF_CLIENT_SOURCE, CONF_IGNORE_WIRED_BUG, CONF_SSID_FILTER, CONF_TRACK_CLIENTS, @@ -132,21 +133,29 @@ async def test_tracked_clients( "last_seen": None, "mac": "00:00:00:00:00:05", } + client_6 = { + "hostname": "client_6", + "ip": "10.0.0.6", + "is_wired": True, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:06", + } await setup_unifi_integration( hass, aioclient_mock, - options={CONF_SSID_FILTER: ["ssid"]}, - clients_response=[client_1, client_2, client_3, client_4, client_5], + options={CONF_SSID_FILTER: ["ssid"], CONF_CLIENT_SOURCE: [client_6["mac"]]}, + clients_response=[client_1, client_2, client_3, client_4, client_5, client_6], known_wireless_clients=(client_4["mac"],), ) - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 4 + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 5 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" ) + assert hass.states.get("device_tracker.client_6").state == STATE_NOT_HOME # Client on SSID not in SSID filter assert not hass.states.get("device_tracker.client_3") diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index b652c38abdb..f4366b98fc3 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -8,9 +8,11 @@ import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, SCAN_INTERVAL, SensorDeviceClass, + SensorStateClass, ) from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, @@ -354,16 +356,28 @@ async def test_bandwidth_sensors( assert len(hass.states.async_all()) == 5 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 - assert hass.states.get("sensor.wired_client_rx").state == "1234.0" - assert hass.states.get("sensor.wired_client_tx").state == "5678.0" - assert hass.states.get("sensor.wireless_client_rx").state == "2345.0" - assert hass.states.get("sensor.wireless_client_tx").state == "6789.0" - ent_reg = er.async_get(hass) - assert ( - ent_reg.async_get("sensor.wired_client_rx").entity_category - is EntityCategory.DIAGNOSTIC - ) + # Verify sensor attributes and state + + wrx_sensor = hass.states.get("sensor.wired_client_rx") + assert wrx_sensor.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_RATE + assert wrx_sensor.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert wrx_sensor.state == "1234.0" + + wtx_sensor = hass.states.get("sensor.wired_client_tx") + assert wtx_sensor.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_RATE + assert wtx_sensor.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert wtx_sensor.state == "5678.0" + + wlrx_sensor = hass.states.get("sensor.wireless_client_rx") + assert wlrx_sensor.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_RATE + assert wlrx_sensor.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert wlrx_sensor.state == "2345.0" + + wltx_sensor = hass.states.get("sensor.wireless_client_tx") + assert wltx_sensor.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_RATE + assert wltx_sensor.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert wltx_sensor.state == "6789.0" # Verify state update diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index a08cf0be688..cfcfbe6c3ed 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -771,7 +771,7 @@ async def test_no_clients( }, ) - assert aioclient_mock.call_count == 11 + assert aioclient_mock.call_count == 12 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -860,8 +860,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -869,8 +869,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[13][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } @@ -887,8 +887,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[13][2] == {"enabled": False} + assert aioclient_mock.call_count == 15 + assert aioclient_mock.mock_calls[14][2] == {"enabled": False} await hass.services.async_call( SWITCH_DOMAIN, @@ -896,8 +896,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 15 - assert aioclient_mock.mock_calls[14][2] == {"enabled": True} + assert aioclient_mock.call_count == 16 + assert aioclient_mock.mock_calls[15][2] == {"enabled": True} async def test_remove_switches( @@ -983,8 +983,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -992,8 +992,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[13][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index b2d06a642a8..16749167c41 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -176,6 +176,7 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls: list[ServiceCall], enable_custom_integrations: None, @@ -187,7 +188,14 @@ async def test_if_fires_on_state_change( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get("update.update_available") + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -198,7 +206,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -222,7 +230,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": "update.update_available", "type": "turned_off", }, @@ -270,6 +278,7 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls: list[ServiceCall], enable_custom_integrations: None, @@ -281,7 +290,14 @@ async def test_if_fires_on_state_change_legacy( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get("update.update_available") + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -292,7 +308,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -332,6 +348,7 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls: list[ServiceCall], enable_custom_integrations: None, @@ -343,7 +360,14 @@ async def test_if_fires_on_state_change_with_for( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get("update.update_available") + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -354,7 +378,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 539ba640d80..a078d82ba9f 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'uptime', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unit_of_measurement': None, @@ -57,6 +58,7 @@ 'model': None, 'name': 'Uptime', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -78,6 +80,7 @@ 'model': None, 'name': 'Uptime', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 43d68d87362..2c64338c4f3 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1266,7 +1266,9 @@ async def _test_self_reset( state = hass.states.get("sensor.energy_bill") if expect_reset: assert state.attributes.get("last_period") == "2" - assert state.attributes.get("last_reset") == now.isoformat() + assert ( + state.attributes.get("last_reset") == dt_util.as_utc(now).isoformat() + ) # last_reset is kept in UTC assert state.state == "3" else: assert state.attributes.get("last_period") == "0" @@ -1348,6 +1350,16 @@ async def test_self_reset_hourly(hass: HomeAssistant) -> None: ) +async def test_self_reset_hourly_dst(hass: HomeAssistant) -> None: + """Test hourly reset of meter in DST change conditions.""" + + hass.config.time_zone = "Europe/Lisbon" + dt_util.set_default_time_zone(dt_util.get_time_zone(hass.config.time_zone)) + await _test_self_reset( + hass, gen_config("hourly"), "2023-10-29T01:59:00.000000+00:00" + ) + + async def test_self_reset_daily(hass: HomeAssistant) -> None: """Test daily reset of meter.""" await _test_self_reset( diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index 617b8d41609..cf0ab3c20d8 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -102,9 +102,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -115,7 +127,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_dock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "dock", }, @@ -124,7 +136,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_clean"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "clean", }, @@ -151,10 +163,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -165,7 +187,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_event_dock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "dock", }, diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 694f4b64417..a2ba75cc752 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -115,10 +115,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -133,7 +144,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_cleaning", } @@ -151,7 +162,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_docked", } @@ -189,10 +200,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLEANING) @@ -207,7 +229,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_cleaning", } diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 2f27d299d7e..605dd6e5b9f 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -178,10 +178,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -194,7 +205,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "cleaning", }, @@ -213,7 +224,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "docked", }, @@ -252,10 +263,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -268,7 +290,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "cleaning", }, @@ -298,10 +320,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -314,7 +347,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "cleaning", "for": {"seconds": 5}, diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index eaa39bceaec..7c5c0de1674 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -36,8 +36,30 @@ def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: yield +ISSUE_TRACKER = "https://blablabla.com" + + +@pytest.mark.parametrize( + ("manifest_extra", "translation_key", "translation_placeholders_extra"), + [ + ( + {}, + "deprecated_vacuum_base_class", + {}, + ), + ( + {"issue_tracker": ISSUE_TRACKER}, + "deprecated_vacuum_base_class_url", + {"issue_tracker": ISSUE_TRACKER}, + ), + ], +) async def test_deprecated_base_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + manifest_extra: dict[str, str], + translation_key: str, + translation_placeholders_extra: dict[str, str], ) -> None: """Test warnings when adding VacuumEntity to the state machine.""" @@ -54,7 +76,9 @@ async def test_deprecated_base_class( MockModule( TEST_DOMAIN, async_setup_entry=async_setup_entry_init, + partial_manifest=manifest_extra, ), + built_in=False, ) entity1 = VacuumEntity() @@ -91,3 +115,9 @@ async def test_deprecated_base_class( VACUUM_DOMAIN, f"deprecated_vacuum_base_class_{TEST_DOMAIN}" ) assert issue.issue_domain == TEST_DOMAIN + assert issue.issue_id == f"deprecated_vacuum_base_class_{TEST_DOMAIN}" + assert issue.translation_key == translation_key + assert ( + issue.translation_placeholders + == {"platform": "test"} | translation_placeholders_extra + ) diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 10dfdd2ba14..b2ae7b53cf5 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -100,6 +100,7 @@ }), 'device_image': 'https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png', 'device_name': 'Humidifier', + 'device_region': 'US', 'device_status': 'off', 'device_type': 'LUH-A602S-WUS', 'enabled': False, @@ -128,6 +129,7 @@ ]), 'mode': None, 'night_light': True, + 'pid': None, 'speed': None, 'sub_device_no': None, 'type': 'wifi-air', @@ -174,6 +176,7 @@ }), 'device_image': '', 'device_name': 'Fan', + 'device_region': 'US', 'device_status': 'unknown', 'device_type': 'LV-PUR131S', 'extension': None, @@ -264,6 +267,7 @@ 'mac_id': '**REDACTED**', 'manager': '**REDACTED**', 'mode': None, + 'pid': None, 'speed': None, 'sub_device_no': None, 'type': 'wifi-air', diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index fa1a7a7b332..fa60aec2422 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -22,6 +22,7 @@ 'model': 'LV-PUR131S', 'name': 'Air Purifier 131s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -58,6 +59,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'air-purifier', @@ -105,6 +107,7 @@ 'model': 'Core200S', 'name': 'Air Purifier 200s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -140,6 +143,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', @@ -193,6 +197,7 @@ 'model': 'LAP-C401S-WJP', 'name': 'Air Purifier 400s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -229,6 +234,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '400s-purifier', @@ -283,6 +289,7 @@ 'model': 'LAP-C601S-WUS', 'name': 'Air Purifier 600s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -319,6 +326,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '600s-purifier', @@ -373,6 +381,7 @@ 'model': 'ESL100', 'name': 'Dimmable Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -406,6 +415,7 @@ 'model': 'ESWD16', 'name': 'Dimmer Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -455,6 +465,7 @@ 'model': 'wifi-switch-1.3', 'name': 'Outlet', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -488,6 +499,7 @@ 'model': 'ESL100CW', 'name': 'Temperature Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -521,6 +533,7 @@ 'model': 'ESWL01', 'name': 'Wall Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 67940603d41..0ccc169a4ce 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -22,6 +22,7 @@ 'model': 'LV-PUR131S', 'name': 'Air Purifier 131s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -55,6 +56,7 @@ 'model': 'Core200S', 'name': 'Air Purifier 200s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -88,6 +90,7 @@ 'model': 'LAP-C401S-WJP', 'name': 'Air Purifier 400s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -121,6 +124,7 @@ 'model': 'LAP-C601S-WUS', 'name': 'Air Purifier 600s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -154,6 +158,7 @@ 'model': 'ESL100', 'name': 'Dimmable Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -189,6 +194,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'dimmable-bulb', @@ -235,6 +241,7 @@ 'model': 'ESWD16', 'name': 'Dimmer Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -270,6 +277,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'dimmable-switch', @@ -334,6 +342,7 @@ 'model': 'wifi-switch-1.3', 'name': 'Outlet', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -367,6 +376,7 @@ 'model': 'ESL100CW', 'name': 'Temperature Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -406,6 +416,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'tunable-bulb', @@ -416,15 +427,22 @@ # name: test_light_state[Temperature Light][light.temperature_light] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Temperature Light', + 'hs_color': None, 'max_color_temp_kelvin': 6493, 'max_mireds': 370, 'min_color_temp_kelvin': 2702, 'min_mireds': 154, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'context': , 'entity_id': 'light.temperature_light', @@ -456,6 +474,7 @@ 'model': 'ESWL01', 'name': 'Wall Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 06198bca145..bbfc9390634 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'LV-PUR131S', 'name': 'Air Purifier 131s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -55,6 +56,7 @@ 'original_icon': None, 'original_name': 'Filter lifetime', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': 'air-purifier-filter-life', @@ -83,6 +85,7 @@ 'original_icon': None, 'original_name': 'Air quality', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': 'air-purifier-air-quality', @@ -139,6 +142,7 @@ 'model': 'Core200S', 'name': 'Air Purifier 200s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -172,6 +176,7 @@ 'original_icon': None, 'original_name': 'Filter lifetime', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-filter-life', @@ -216,6 +221,7 @@ 'model': 'LAP-C401S-WJP', 'name': 'Air Purifier 400s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -249,6 +255,7 @@ 'original_icon': None, 'original_name': 'Filter lifetime', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': '400s-purifier-filter-life', @@ -277,6 +284,7 @@ 'original_icon': None, 'original_name': 'Air quality', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '400s-purifier-air-quality', @@ -307,6 +315,7 @@ 'original_icon': None, 'original_name': 'PM2.5', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '400s-purifier-pm25', @@ -378,6 +387,7 @@ 'model': 'LAP-C601S-WUS', 'name': 'Air Purifier 600s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -411,6 +421,7 @@ 'original_icon': None, 'original_name': 'Filter lifetime', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': '600s-purifier-filter-life', @@ -439,6 +450,7 @@ 'original_icon': None, 'original_name': 'Air quality', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '600s-purifier-air-quality', @@ -469,6 +481,7 @@ 'original_icon': None, 'original_name': 'PM2.5', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '600s-purifier-pm25', @@ -540,6 +553,7 @@ 'model': 'ESL100', 'name': 'Dimmable Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -573,6 +587,7 @@ 'model': 'ESWD16', 'name': 'Dimmer Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -622,6 +637,7 @@ 'model': 'wifi-switch-1.3', 'name': 'Outlet', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -655,6 +671,7 @@ 'original_icon': None, 'original_name': 'Current power', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_power', 'unique_id': 'outlet-power', @@ -685,6 +702,7 @@ 'original_icon': None, 'original_name': 'Energy use today', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_today', 'unique_id': 'outlet-energy', @@ -715,6 +733,7 @@ 'original_icon': None, 'original_name': 'Energy use weekly', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_week', 'unique_id': 'outlet-energy-weekly', @@ -745,6 +764,7 @@ 'original_icon': None, 'original_name': 'Energy use monthly', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_month', 'unique_id': 'outlet-energy-monthly', @@ -775,6 +795,7 @@ 'original_icon': None, 'original_name': 'Energy use yearly', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_year', 'unique_id': 'outlet-energy-yearly', @@ -805,6 +826,7 @@ 'original_icon': None, 'original_name': 'Current voltage', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_voltage', 'unique_id': 'outlet-voltage', @@ -925,6 +947,7 @@ 'model': 'ESL100CW', 'name': 'Temperature Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -958,6 +981,7 @@ 'model': 'ESWL01', 'name': 'Wall Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index cfe9d66a2ed..6333356f26a 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -22,6 +22,7 @@ 'model': 'LV-PUR131S', 'name': 'Air Purifier 131s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -55,6 +56,7 @@ 'model': 'Core200S', 'name': 'Air Purifier 200s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -88,6 +90,7 @@ 'model': 'LAP-C401S-WJP', 'name': 'Air Purifier 400s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -121,6 +124,7 @@ 'model': 'LAP-C601S-WUS', 'name': 'Air Purifier 600s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -154,6 +158,7 @@ 'model': 'ESL100', 'name': 'Dimmable Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -187,6 +192,7 @@ 'model': 'ESWD16', 'name': 'Dimmer Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -236,6 +242,7 @@ 'model': 'wifi-switch-1.3', 'name': 'Outlet', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -267,6 +274,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'outlet', @@ -309,6 +317,7 @@ 'model': 'ESL100CW', 'name': 'Temperature Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -342,6 +351,7 @@ 'model': 'ESWL01', 'name': 'Wall Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -373,6 +383,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'switch', diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index 1e80bb26fe7..dc1b217948f 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -1,4698 +1,4700 @@ # 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, + 'data': list([ + dict({ + 'data': list([ + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'starts': dict({ - 'type': 'number', - 'unit': '', - 'value': 14315, + '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', }), - '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', + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - }), - '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, + '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', }), - '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', + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.solar.pumps.circuit', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 63, + '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({ }), - }), - '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', - }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.burners.0.statistics', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'hours': dict({ + 'type': 'number', + 'unit': '', + 'value': 18726.3, }), - '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', - }), + 'starts': dict({ + 'type': 'number', + 'unit': '', + 'value': 14315, }), - '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({ + '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', }), - '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', - }), + '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, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature', }), + 'timestamp': '2021-08-25T14:16:46.543Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burner', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs.comfort', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'demand': dict({ - 'type': 'string', - 'value': 'unknown', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 22, + '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', }), - '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', - }), + 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', }), - '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', - }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', }), - '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', - }), - ]), + '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', }), - '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({ + 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', }), - '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, + 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', }), - 'required': True, - 'type': 'number', }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode', }), - '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', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ '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', - }), - ]), + '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', }), - '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, + 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', }), - 'required': True, - 'type': 'string', + }), + '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', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0/commands/setName', }), + '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', }), - '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, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'name': dict({ - 'type': 'string', - 'value': '', - }), - 'type': dict({ - 'type': 'string', - 'value': 'heatingCircuit', + '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', }), - '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, + 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', }), - 'required': True, - 'type': 'string', }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1/commands/setName', }), + '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', }), - '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, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'name': dict({ - 'type': 'string', - 'value': '', - }), - 'type': dict({ - 'type': 'string', - 'value': 'heatingCircuit', + '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', }), - '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({ + 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', }), - '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, + 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', }), - 'required': True, - 'type': 'Schedule', }), + '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', }), - '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.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', }), - '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, + 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', + }), }), - 'entries': dict({ - 'type': 'Schedule', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ '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', - }), - ]), + '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', }), - '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, + 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', }), - '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', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule/commands/setSchedule', }), + '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', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.dhw.schedule', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'entries': dict({ - 'type': 'Schedule', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.controller.serial', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ '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', - }), - ]), + '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', }), - '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', + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 5, + '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', }), - '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, + 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', }), - 'required': True, - 'type': 'number', }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0/commands/setName', }), - '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', - }), + '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, }), - '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', - }), + 'name': dict({ + 'type': 'string', + 'value': '', }), - '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({ + 'type': dict({ + 'type': 'string', + 'value': 'heatingCircuit', }), - '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', - }), + '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, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/setTemperature', }), + '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', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.comfort', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'demand': dict({ - 'type': 'string', - 'value': 'unknown', - }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 24, + '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', }), - '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, + 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', }), - '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', - }), + 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', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve', }), + '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', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.heating.curve', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'shift': dict({ - 'type': 'number', - 'unit': '', - 'value': 9, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - '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({ + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.dhwAndHeating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, }), - '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({ + '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', }), - '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({ + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', }), - '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', - }), - ]), + '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', }), - '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({ + 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', }), - 'components': list([ - 'eco', - 'holiday', - 'standard', - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.programs', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ + 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', }), - '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, + 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', }), - 'required': True, - 'type': 'Schedule', + }), + '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', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule/commands/setSchedule', }), + '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', }), - '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, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'entries': dict({ - 'type': 'Schedule', + '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({ - 'fri': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', + '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, ]), - 'mon': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '05:30', - }), + }), + '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, ]), - 'sat': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '05:30', - }), + }), + '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, ]), - 'sun': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '06:30', - }), + }), + 'weekValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:47.418Z', + }), + 'year': dict({ + 'type': 'array', + 'value': list([ + 8203, + 12546, + 11741, ]), - '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', - }), + }), + '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', }), - '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, + 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', }), - '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, + 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', }), - 'required': True, - 'type': 'number', + }), + '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', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature', }), + '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', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.temperature.main', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'value': dict({ - 'type': 'number', - 'unit': '', - 'value': 58, + 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', }), - '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, + 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', }), - 'required': True, - 'type': 'number', + }), + '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', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced/commands/setTemperature', }), + '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', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.reduced', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'demand': dict({ - 'type': 'string', - 'value': 'unknown', + 'components': list([ + 'room', + 'supply', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 21, + '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({ }), - }), - '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, + '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', }), - '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, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - }), - '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', - }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.standby', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve/commands/setCurve', }), + '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', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.heating.curve', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'shift': dict({ - 'type': 'number', - 'unit': '', - 'value': 0, + 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', + }), }), - 'slope': dict({ - 'type': 'number', - 'unit': '', - 'value': 1.4, + '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', }), - '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, + 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', + }), }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 0, + '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', }), - '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', + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', + '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', + }), }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 58.6, + '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({ }), - }), - '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', + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ }), - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', + '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({ }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 25.5, + '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', }), - '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({ + 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', }), - 'components': list([ - 'active', - 'dhw', - 'dhwAndHeating', - 'heating', - 'standby', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.modes', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ + 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', }), - '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', - }), - ]), - }), + 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**', diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 0774848ef11..7f70c13f0b0 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -2,7 +2,10 @@ from unittest.mock import AsyncMock, patch import pytest -from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) from syrupy.assertion import SnapshotAssertion from homeassistant.components import dhcp @@ -43,6 +46,22 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {} + # test PyViCareInvalidConfigurationError + with patch( + f"{MODULE}.config_flow.vicare_login", + side_effect=PyViCareInvalidConfigurationError( + {"error": "foo", "error_description": "bar"} + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + 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 PyViCareInvalidCredentialsError with patch( f"{MODULE}.config_flow.vicare_login", diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 41efd8af00c..982a14a80f4 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -18,9 +18,9 @@ from tests.common import MockConfigEntry async def test_user(hass: HomeAssistant) -> None: """Test starting a flow by user.""" with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ) as mock_setup_entry, patch( @@ -67,7 +67,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result["step_id"] == "user" with patch( - "aiovodafone.api.VodafoneStationApi.login", + "aiovodafone.api.VodafoneStationSercommApi.login", side_effect=side_effect, ): result = await hass.config_entries.flow.async_configure( @@ -80,15 +80,15 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> # Should be recoverable after hits error with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_devices_data", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", return_value={ "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", }, ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ): @@ -118,9 +118,9 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: mock_config.add_to_hass(hass) with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ), patch( @@ -165,10 +165,10 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> mock_config.add_to_hass(hass) with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", side_effect=side_effect, ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ): @@ -194,15 +194,15 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> # Should be recoverable after hits error with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_devices_data", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", return_value={ "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", }, ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ): diff --git a/tests/components/vulcan/conftest.py b/tests/components/vulcan/conftest.py new file mode 100644 index 00000000000..05a518ad7f3 --- /dev/null +++ b/tests/components/vulcan/conftest.py @@ -0,0 +1,5 @@ +"""Skip test collection for Python 3.12.""" +import sys + +if sys.version_info >= (3, 12): + collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index b995a066c51..40f55db8d50 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -29,61 +29,69 @@ from .const import ERROR, STATUS, TTL, USER_ID from tests.common import MockConfigEntry -test_response = json.loads( - json.dumps( - { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.2, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - }, - } - ) -) +test_response = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + }, +} -authorisation_response = json.loads( - json.dumps( - { - "data": { - "attributes": { - "token": "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 200, - } - } - } - ) -) +test_response_bidir = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + }, +} -authorisation_response_unauthorised = json.loads( - json.dumps( - { - "data": { - "attributes": { - "token": "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 404, - } - } +authorisation_response = { + "data": { + "attributes": { + "token": "fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + ERROR: "false", + STATUS: 200, } - ) -) + } +} + + +authorisation_response_unauthorised = { + "data": { + "attributes": { + "token": "fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + ERROR: "false", + STATUS: 404, + } + } +} async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: @@ -109,6 +117,29 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None await hass.async_block_till_done() +async def setup_integration_bidir(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test wallbox sensor class setup.""" + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=HTTPStatus.OK, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response_bidir, + status_code=HTTPStatus.OK, + ) + mock_request.put( + "https://api.wall-box.com/v2/charger/12345", + json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), + status_code=HTTPStatus.OK, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + async def setup_integration_connection_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 477fb10d292..4480b1ea7a4 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -6,6 +6,7 @@ ERROR = "error" STATUS = "status" MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" +MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID = "number.wallbox_wallboxname_energy_price" MOCK_LOCK_ENTITY_ID = "lock.wallbox_wallboxname_lock" MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.wallbox_wallboxname_charging_speed" MOCK_SENSOR_CHARGING_POWER_ID = "sensor.wallbox_wallboxname_charging_power" diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 2afe2d245a8..93082737f1f 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -3,7 +3,10 @@ import json import requests_mock -from homeassistant.components.wallbox import CHARGER_MAX_CHARGING_CURRENT_KEY, DOMAIN +from homeassistant.components.wallbox.const import ( + CHARGER_MAX_CHARGING_CURRENT_KEY, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -42,6 +45,34 @@ async def test_wallbox_unload_entry_connection_error( assert entry.state == ConfigEntryState.NOT_LOADED +async def test_wallbox_refresh_failed_connection_error_auth( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox setup with connection error.""" + + await setup_integration(hass, entry) + assert entry.state == ConfigEntryState.LOADED + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=404, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response, + status_code=200, + ) + + wallbox = hass.data[DOMAIN][entry.entry_id] + + await wallbox.async_refresh() + + 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, entry: MockConfigEntry ) -> None: diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index f812d27d8c2..065a43b2789 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -5,7 +5,7 @@ import pytest import requests_mock from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK -from homeassistant.components.wallbox import CHARGER_LOCKED_UNLOCKED_KEY +from homeassistant.components.wallbox.const import CHARGER_LOCKED_UNLOCKED_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 8f3e6274220..41ebedc91da 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -5,16 +5,21 @@ import pytest import requests_mock from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE -from homeassistant.components.wallbox import CHARGER_MAX_CHARGING_CURRENT_KEY +from homeassistant.components.wallbox.const import ( + CHARGER_ENERGY_PRICE_KEY, + CHARGER_MAX_CHARGING_CURRENT_KEY, +) +from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from . import ( authorisation_response, setup_integration, + setup_integration_bidir, setup_integration_platform_not_ready, ) -from .const import MOCK_NUMBER_ENTITY_ID +from .const import MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, MOCK_NUMBER_ENTITY_ID from tests.common import MockConfigEntry @@ -37,6 +42,9 @@ async def test_wallbox_number_class( json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), status_code=200, ) + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == 0 + assert state.attributes["max"] == 25 await hass.services.async_call( "number", @@ -50,6 +58,51 @@ async def test_wallbox_number_class( await hass.config_entries.async_unload(entry.entry_id) +async def test_wallbox_number_class_bidir( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration_bidir(hass, entry) + + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == -25 + assert state.attributes["max"] == 25 + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_wallbox_number_energy_class( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + status_code=200, + ) + + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + async def test_wallbox_number_class_connection_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -82,6 +135,70 @@ async def test_wallbox_number_class_connection_error( await hass.config_entries.async_unload(entry.entry_id) +async def test_wallbox_number_class_energy_price_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + status_code=404, + ) + + with pytest.raises(ConnectionError): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_wallbox_number_class_energy_price_auth_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + status_code=403, + ) + + with pytest.raises(InvalidAuth): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + async def test_wallbox_number_class_platform_not_ready( hass: HomeAssistant, entry: MockConfigEntry ) -> None: diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index 2b4d49b5af9..9418b4d8765 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -5,8 +5,8 @@ import pytest import requests_mock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON -from homeassistant.components.wallbox import InvalidAuth from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY +from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant diff --git a/tests/components/waqi/fixtures/air_quality_sensor.json b/tests/components/waqi/fixtures/air_quality_sensor.json index 49f1184822f..885ecd8dfa2 100644 --- a/tests/components/waqi/fixtures/air_quality_sensor.json +++ b/tests/components/waqi/fixtures/air_quality_sensor.json @@ -23,9 +23,18 @@ "h": { "v": 80 }, + "neph": { + "v": 80 + }, + "co": { + "v": 2.3 + }, "no2": { "v": 2.3 }, + "so2": { + "v": 2.3 + }, "o3": { "v": 29.4 }, diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..029b36b3c16 --- /dev/null +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -0,0 +1,195 @@ +# serializer version: 1 +# name: test_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'aqi', + 'dominentpol': , + 'friendly_name': 'de Jongweg, Utrecht Air quality index', + 'humidity': 80, + 'nitrogen_dioxide': 2.3, + 'ozone': 29.4, + 'pm_10': 12, + 'pm_2_5': 17, + 'pressure': 1008.8, + 'state_class': , + 'sulfur_dioxide': 2.3, + 'temperature': 16, + 'time': datetime.datetime(2023, 8, 7, 17, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200))), + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_air_quality_index', + 'last_changed': , + 'last_updated': , + 'state': '29', + }) +# --- +# name: test_sensor.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'humidity', + 'friendly_name': 'de Jongweg, Utrecht Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_humidity', + 'last_changed': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Visbility using nephelometry', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_visbility_using_nephelometry', + 'last_changed': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor.11 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'enum', + 'friendly_name': 'de Jongweg, Utrecht Dominant pollutant', + 'options': list([ + 'co', + 'no2', + 'o3', + 'so2', + 'pm10', + 'pm25', + 'neph', + ]), + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_dominant_pollutant', + 'last_changed': , + 'last_updated': , + 'state': 'o3', + }) +# --- +# name: test_sensor.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'pressure', + 'friendly_name': 'de Jongweg, Utrecht Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1008.8', + }) +# --- +# name: test_sensor.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'temperature', + 'friendly_name': 'de Jongweg, Utrecht Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_temperature', + 'last_changed': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_sensor.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', + 'last_changed': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor.5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', + 'last_changed': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Ozone', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_ozone', + 'last_changed': , + 'last_updated': , + 'state': '29.4', + }) +# --- +# name: test_sensor.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', + 'last_changed': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM10', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm10', + 'last_changed': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensor.9 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM2.5', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', + 'last_changed': , + 'last_updated': , + 'state': '17', + }) +# --- diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 7feb37a1b09..ebe0c87736d 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -3,10 +3,12 @@ import json from unittest.mock import patch from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult +import pytest +from syrupy import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN -from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS +from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS, SENSORS from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, @@ -72,7 +74,7 @@ async def test_legacy_migration_already_imported( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + state = hass.states.get("sensor.de_jongweg_utrecht_air_quality_index") assert state.state == "29" hass.async_create_task( @@ -114,13 +116,16 @@ async def test_sensor_id_migration( entities = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - assert len(entities) == 1 + assert len(entities) == 12 assert hass.states.get("sensor.waqi_4584") - assert hass.states.get("sensor.waqi_de_jongweg_utrecht") is None + assert hass.states.get("sensor.de_jongweg_utrecht_air_quality_index") is None assert entities[0].unique_id == "4584_air_quality" -async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: """Test failed update.""" mock_config_entry.add_to_hass(hass) with patch( @@ -131,9 +136,12 @@ async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) - ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - - state = hass.states.get("sensor.waqi_de_jongweg_utrecht") - assert state.state == "29" + entity_registry = er.async_get(hass) + for sensor in SENSORS: + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"4584_{sensor.key}" + ) + assert hass.states.get(entity_id) == snapshot async def test_updating_failed( diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index a8ca41905d6..8254fb77a77 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -102,9 +102,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -118,7 +130,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -130,7 +142,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -157,10 +169,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -174,7 +196,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, diff --git a/tests/components/weather/snapshots/test_init.ambr b/tests/components/weather/snapshots/test_init.ambr new file mode 100644 index 00000000000..03a2d46c80f --- /dev/null +++ b/tests/components/weather/snapshots/test_init.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_get_forecast[daily-1] + dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }) +# --- +# name: test_get_forecast[hourly-2] + dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }) +# --- +# name: test_get_forecast[twice_daily-4] + dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'is_daytime': True, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }) +# --- diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index db3a18db914..f17edb16f07 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1,20 +1,32 @@ """The test for weather entity.""" +from collections.abc import Generator from datetime import datetime from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, ATTR_FORECAST_APPARENT_TEMP, + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_DEW_POINT, ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_DEW_POINT, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_UV_INDEX, + ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_GUST_SPEED, ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_APPARENT_TEMPERATURE, @@ -44,6 +56,7 @@ from homeassistant.components.weather.const import ( ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ) +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, @@ -56,6 +69,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -69,6 +83,14 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from . import create_entity +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) from tests.testing_config.custom_components.test import weather as WeatherPlatform from tests.testing_config.custom_components.test_weather import ( weather as NewWeatherPlatform, @@ -833,14 +855,13 @@ async def test_forecast_twice_daily_missing_is_daytime( @pytest.mark.parametrize( - ("forecast_type", "supported_features", "extra"), + ("forecast_type", "supported_features"), [ - ("daily", WeatherEntityFeature.FORECAST_DAILY, {}), - ("hourly", WeatherEntityFeature.FORECAST_HOURLY, {}), + ("daily", WeatherEntityFeature.FORECAST_DAILY), + ("hourly", WeatherEntityFeature.FORECAST_HOURLY), ( "twice_daily", WeatherEntityFeature.FORECAST_TWICE_DAILY, - {"is_daytime": True}, ), ], ) @@ -849,7 +870,7 @@ async def test_get_forecast( enable_custom_integrations: None, forecast_type: str, supported_features: int, - extra: dict[str, Any], + snapshot: SnapshotAssertion, ) -> None: """Test get forecast service.""" @@ -870,18 +891,7 @@ async def test_get_forecast( blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "cloud_coverage": None, - "temperature": 38.0, - "templow": 38.0, - "uv_index": None, - "wind_bearing": None, - } - | extra - ], - } + assert response == snapshot async def test_get_forecast_no_forecast( @@ -950,7 +960,150 @@ async def test_get_forecast_unsupported( ) -async def test_issue_forecast_deprecated( +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 + + +ISSUE_TRACKER = "https://blablabla.com" + + +@pytest.mark.parametrize( + ("manifest_extra", "translation_key", "translation_placeholders_extra", "report"), + [ + ( + {}, + "deprecated_weather_forecast_no_url", + {}, + "report it to the author of the 'test' custom integration", + ), + ( + {"issue_tracker": ISSUE_TRACKER}, + "deprecated_weather_forecast_url", + {"issue_tracker": ISSUE_TRACKER}, + "create a bug report at https://blablabla.com", + ), + ], +) +async def test_issue_forecast_property_deprecated( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_flow_fixture: None, + manifest_extra: dict[str, str], + translation_key: str, + translation_placeholders_extra: dict[str, str], + report: str, +) -> None: + """Test the issue is raised on deprecated forecast attributes.""" + + class MockWeatherMockLegacyForecastOnly(WeatherPlatform.MockWeather): + """Mock weather class with mocked legacy forecast.""" + + def __init__(self, **values: Any) -> None: + """Initialize.""" + super().__init__(**values) + self.forecast_list: list[Forecast] | None = [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + } + ] + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + # Fake that the class belongs to a custom integration + MockWeatherMockLegacyForecastOnly.__module__ = "custom_components.test.weather" + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + weather_entity = MockWeatherMockLegacyForecastOnly( + name="Testing", + entity_id="weather.testing", + condition=ATTR_CONDITION_SUNNY, + **kwargs, + ) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_weather_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test weather platform via config entry.""" + async_add_entities([weather_entity]) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + partial_manifest=manifest_extra, + ), + built_in=False, + ) + mock_platform( + hass, + "test.weather", + MockPlatform(async_setup_entry=async_setup_entry_weather_platform), + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert weather_entity.state == ATTR_CONDITION_SUNNY + + issues = ir.async_get(hass) + issue = issues.async_get_issue("weather", "deprecated_weather_forecast_test") + assert issue + assert issue.issue_domain == "test" + assert issue.issue_id == "deprecated_weather_forecast_test" + assert issue.translation_key == translation_key + assert ( + issue.translation_placeholders + == {"platform": "test"} | translation_placeholders_extra + ) + + assert ( + "test::MockWeatherMockLegacyForecastOnly implements the `forecast` property or " + "sets `self._attr_forecast` in a subclass of WeatherEntity, this is deprecated " + f"and will be unsupported from Home Assistant 2024.3. Please {report}" + ) in caplog.text + + +async def test_issue_forecast_attr_deprecated( hass: HomeAssistant, enable_custom_integrations: None, caplog: pytest.LogCaptureFixture, @@ -964,14 +1117,14 @@ async def test_issue_forecast_deprecated( platform: WeatherPlatform = getattr(hass.components, "test.weather") caplog.clear() platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockLegacyForecastOnly( - name="Testing", - entity_id="weather.testing", - condition=ATTR_CONDITION_SUNNY, - **kwargs, - ) + weather = platform.MockWeather( + name="Testing", + entity_id="weather.testing", + condition=ATTR_CONDITION_SUNNY, + **kwargs, ) + weather._attr_forecast = [] + platform.ENTITIES.append(weather) entity0 = platform.ENTITIES[0] assert await async_setup_component( @@ -986,15 +1139,15 @@ async def test_issue_forecast_deprecated( assert issue assert issue.issue_domain == "test" assert issue.issue_id == "deprecated_weather_forecast_test" - assert issue.translation_placeholders == { - "platform": "test", - "report_issue": "report it to the custom integration author.", - } + assert issue.translation_key == "deprecated_weather_forecast_no_url" + assert issue.translation_placeholders == {"platform": "test"} assert ( - "custom_components.test.weather::weather.testing is using a forecast attribute on an instance of WeatherEntity" - in caplog.text - ) + "test::MockWeather implements the `forecast` property or " + "sets `self._attr_forecast` in a subclass of WeatherEntity, this is deprecated " + "and will be unsupported from Home Assistant 2024.3. Please report it to the " + "author of the 'test' custom integration" + ) in caplog.text async def test_issue_forecast_deprecated_no_logging( diff --git a/tests/components/weatherkit/fixtures/weather_response.json b/tests/components/weatherkit/fixtures/weather_response.json index c2d619d85d8..7a38347bdf5 100644 --- a/tests/components/weatherkit/fixtures/weather_response.json +++ b/tests/components/weatherkit/fixtures/weather_response.json @@ -19,7 +19,7 @@ "conditionCode": "PartlyCloudy", "daylight": true, "humidity": 0.91, - "precipitationIntensity": 0.0, + "precipitationIntensity": 0.7, "pressure": 1009.8, "pressureTrend": "rising", "temperature": 22.9, diff --git a/tests/components/weatherkit/test_sensor.py b/tests/components/weatherkit/test_sensor.py new file mode 100644 index 00000000000..6c6999c6bfd --- /dev/null +++ b/tests/components/weatherkit/test_sensor.py @@ -0,0 +1,27 @@ +"""Sensor entity tests for the WeatherKit integration.""" + +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant + +from . import init_integration + + +@pytest.mark.parametrize( + ("entity_name", "expected_value"), + [ + ("sensor.home_precipitation_intensity", 0.7), + ("sensor.home_pressure_trend", "rising"), + ], +) +async def test_sensor_values( + hass: HomeAssistant, entity_name: str, expected_value: Any +) -> None: + """Test that various sensor values match what we expect.""" + await init_integration(hass) + + state = hass.states.get(entity_name) + assert state + assert state.state == str(expected_value) diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 6aafb9f2685..35ed55183d4 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -2,7 +2,7 @@ import pytest from homeassistant.components.websocket_api.messages import ( - _cached_event_message as lru_event_cache, + _partial_cached_event_message as lru_event_cache, _state_diff_event, cached_event_message, message_to_json, diff --git a/tests/components/wemo/test_device_trigger.py b/tests/components/wemo/test_device_trigger.py index 4ae8dcaddb1..9140f5f1e35 100644 --- a/tests/components/wemo/test_device_trigger.py +++ b/tests/components/wemo/test_device_trigger.py @@ -19,7 +19,6 @@ from homeassistant.setup import async_setup_component from tests.common import async_get_device_automations, async_mock_service -MOCK_DEVICE_ID = "some-device-id" DATA_MESSAGE = {"message": "service-called"} @@ -96,12 +95,12 @@ async def test_get_triggers(hass: HomeAssistant, wemo_entity) -> None: assert triggers == unordered(expected_triggers) -async def test_fires_on_long_press(hass: HomeAssistant) -> None: +async def test_fires_on_long_press(hass: HomeAssistant, wemo_entity) -> None: """Test wemo long press trigger firing.""" - assert await setup_automation(hass, MOCK_DEVICE_ID, EVENT_TYPE_LONG_PRESS) + assert await setup_automation(hass, wemo_entity.device_id, EVENT_TYPE_LONG_PRESS) calls = async_mock_service(hass, "test", "automation") - message = {CONF_DEVICE_ID: MOCK_DEVICE_ID, CONF_TYPE: EVENT_TYPE_LONG_PRESS} + message = {CONF_DEVICE_ID: wemo_entity.device_id, CONF_TYPE: EVENT_TYPE_LONG_PRESS} hass.bus.async_fire(WEMO_SUBSCRIPTION_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 464af13c7c8..83ac2908089 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': 'mdi:account-star', 'original_name': 'Admin', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admin', 'unique_id': 'home-assistant.io_admin', @@ -64,6 +65,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -106,6 +108,7 @@ 'original_icon': None, 'original_name': 'Created', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'creation_date', 'unique_id': 'home-assistant.io_creation_date', @@ -134,6 +137,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -181,6 +185,7 @@ 'original_icon': 'mdi:calendar-clock', 'original_name': 'Days until expiration', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'days_until_expiration', 'unique_id': 'home-assistant.io_days_until_expiration', @@ -209,6 +214,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -251,6 +257,7 @@ 'original_icon': None, 'original_name': 'Expires', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'expiration_date', 'unique_id': 'home-assistant.io_expiration_date', @@ -279,6 +286,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -321,6 +329,7 @@ 'original_icon': None, 'original_name': 'Last updated', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'last_updated', 'unique_id': 'home-assistant.io_last_updated', @@ -349,6 +358,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -391,6 +401,7 @@ 'original_icon': 'mdi:account', 'original_name': 'Owner', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'owner', 'unique_id': 'home-assistant.io_owner', @@ -419,6 +430,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -461,6 +473,7 @@ 'original_icon': 'mdi:account-edit', 'original_name': 'Registrant', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'registrant', 'unique_id': 'home-assistant.io_registrant', @@ -489,6 +502,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -531,6 +545,7 @@ 'original_icon': 'mdi:store', 'original_name': 'Registrar', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'registrar', 'unique_id': 'home-assistant.io_registrar', @@ -559,6 +574,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -601,6 +617,7 @@ 'original_icon': 'mdi:store', 'original_name': 'Reseller', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'reseller', 'unique_id': 'home-assistant.io_reseller', @@ -629,6 +646,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -671,6 +689,7 @@ 'original_icon': None, 'original_name': 'Last updated', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'last_updated', 'unique_id': 'home-assistant.io_last_updated', diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 4d9a0e841b7..cd0e9994f74 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -5,13 +5,19 @@ from typing import Any from urllib.parse import urlparse from aiohttp.test_utils import TestClient +from aiowithings import Activity, Goals, MeasurementGroup, SleepSummary, Workout from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_array_fixture, + load_json_object_fixture, +) @dataclass @@ -51,7 +57,7 @@ async def setup_integration( if enable_webhooks: await async_process_ha_core_config( hass, - {"external_url": "https://example.local:8123"}, + {"external_url": "https://example.com"}, ) await hass.config_entries.async_setup(config_entry.entry_id) @@ -64,3 +70,41 @@ async def prepare_webhook_setup( freezer.tick(timedelta(seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() + + +def load_goals_fixture(fixture: str = "withings/goals.json") -> Goals: + """Return goals from fixture.""" + goals_json = load_json_object_fixture(fixture) + return Goals.from_api(goals_json) + + +def load_measurements_fixture( + fixture: str = "withings/measurements.json", +) -> list[MeasurementGroup]: + """Return measurement from fixture.""" + meas_json = load_json_array_fixture(fixture) + return [MeasurementGroup.from_api(measurement) for measurement in meas_json] + + +def load_activity_fixture( + fixture: str = "withings/activity.json", +) -> list[Activity]: + """Return activities from fixture.""" + activity_json = load_json_array_fixture(fixture) + return [Activity.from_api(activity) for activity in activity_json] + + +def load_workout_fixture( + fixture: str = "withings/workouts.json", +) -> list[Workout]: + """Return workouts from fixture.""" + workouts_json = load_json_array_fixture(fixture) + return [Workout.from_api(workout) for workout in workouts_json] + + +def load_sleep_fixture( + fixture: str = "withings/sleep_summaries.json", +) -> list[SleepSummary]: + """Return sleep summaries from fixture.""" + sleep_json = load_json_array_fixture("withings/sleep_summaries.json") + return [SleepSummary.from_api(sleep_summary) for sleep_summary in sleep_json] diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index ad310639b43..7f15c5e0252 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -3,24 +3,26 @@ from datetime import timedelta import time from unittest.mock import AsyncMock, patch +from aiowithings import Device, WithingsClient +from aiowithings.models import NotificationConfiguration import pytest -from withings_api import ( - MeasureGetMeasResponse, - NotifyListResponse, - SleepGetSummaryResponse, - UserGetDeviceResponse, -) from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.withings.api import ConfigEntryWithingsApi from homeassistant.components.withings.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, load_json_array_fixture +from tests.components.withings import ( + load_activity_fixture, + load_goals_fixture, + load_measurements_fixture, + load_sleep_fixture, + load_workout_fixture, +) CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -133,22 +135,34 @@ def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: def mock_withings(): """Mock withings.""" - mock = AsyncMock(spec=ConfigEntryWithingsApi) - mock.user_get_device.return_value = UserGetDeviceResponse( - **load_json_object_fixture("withings/get_device.json") - ) - mock.async_measure_get_meas.return_value = MeasureGetMeasResponse( - **load_json_object_fixture("withings/get_meas.json") - ) - mock.async_sleep_get_summary.return_value = SleepGetSummaryResponse( - **load_json_object_fixture("withings/get_sleep.json") - ) - mock.async_notify_list.return_value = NotifyListResponse( - **load_json_object_fixture("withings/notify_list.json") - ) + devices_json = load_json_array_fixture("withings/devices.json") + devices = [Device.from_api(device) for device in devices_json] + + measurement_groups = load_measurements_fixture() + + notification_json = load_json_array_fixture("withings/notifications.json") + notifications = [ + NotificationConfiguration.from_api(not_conf) for not_conf in notification_json + ] + + workouts = load_workout_fixture() + + activities = load_activity_fixture() + + mock = AsyncMock(spec=WithingsClient) + mock.get_devices.return_value = devices + mock.get_goals.return_value = load_goals_fixture() + mock.get_measurement_in_period.return_value = measurement_groups + mock.get_measurement_since.return_value = measurement_groups + mock.get_sleep_summary_since.return_value = load_sleep_fixture() + mock.get_activities_since.return_value = activities + mock.get_activities_in_period.return_value = activities + mock.list_notification_configurations.return_value = notifications + mock.get_workouts_since.return_value = workouts + mock.get_workouts_in_period.return_value = workouts with patch( - "homeassistant.components.withings.ConfigEntryWithingsApi", + "homeassistant.components.withings.WithingsClient", return_value=mock, ): yield mock diff --git a/tests/components/withings/fixtures/activity.json b/tests/components/withings/fixtures/activity.json new file mode 100644 index 00000000000..8ba9f526afa --- /dev/null +++ b/tests/components/withings/fixtures/activity.json @@ -0,0 +1,282 @@ +[ + { + "steps": 1892, + "distance": 1607.93, + "elevation": 0, + "soft": 4981, + "moderate": 158, + "intense": 0, + "active": 158, + "calories": 204.796, + "totalcalories": 2454.481, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-08", + "modified": 1697038118, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 2576, + "distance": 2349.617, + "elevation": 0, + "soft": 1255, + "moderate": 1211, + "intense": 0, + "active": 1211, + "calories": 134.967, + "totalcalories": 2351.652, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-09", + "modified": 1697038118, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1827, + "distance": 1595.537, + "elevation": 0, + "soft": 2194, + "moderate": 569, + "intense": 0, + "active": 569, + "calories": 110.223, + "totalcalories": 2313.98, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-10", + "modified": 1697057517, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 3801, + "distance": 3307.985, + "elevation": 0, + "soft": 5146, + "moderate": 963, + "intense": 0, + "active": 963, + "calories": 240.89, + "totalcalories": 2385.746, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-11", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 2501, + "distance": 2158.186, + "elevation": 0, + "soft": 1854, + "moderate": 998, + "intense": 0, + "active": 998, + "calories": 113.123, + "totalcalories": 2317.396, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-12", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 6787, + "distance": 6008.779, + "elevation": 0, + "soft": 3773, + "moderate": 2831, + "intense": 36, + "active": 2867, + "calories": 263.371, + "totalcalories": 2380.669, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-13", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1232, + "distance": 1050.925, + "elevation": 0, + "soft": 2950, + "moderate": 196, + "intense": 0, + "active": 196, + "calories": 124.754, + "totalcalories": 2311.674, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-14", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 851, + "distance": 723.139, + "elevation": 0, + "soft": 1634, + "moderate": 83, + "intense": 0, + "active": 83, + "calories": 68.121, + "totalcalories": 2294.325, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-15", + "modified": 1697842184, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 654, + "distance": 557.509, + "elevation": 0, + "soft": 1558, + "moderate": 124, + "intense": 0, + "active": 124, + "calories": 66.707, + "totalcalories": 2292.897, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-16", + "modified": 1697842184, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 566, + "distance": 482.185, + "elevation": 0, + "soft": 1085, + "moderate": 52, + "intense": 0, + "active": 52, + "calories": 45.126, + "totalcalories": 2287.08, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-17", + "modified": 1697842184, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 2204, + "distance": 1901.651, + "elevation": 0, + "soft": 1393, + "moderate": 941, + "intense": 0, + "active": 941, + "calories": 92.585, + "totalcalories": 2302.971, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-18", + "modified": 1697842185, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 95, + "distance": 80.63, + "elevation": 0, + "soft": 543, + "moderate": 0, + "intense": 0, + "active": 0, + "calories": 21.541, + "totalcalories": 2277.668, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-19", + "modified": 1697842185, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1209, + "distance": 1028.559, + "elevation": 0, + "soft": 1864, + "moderate": 292, + "intense": 0, + "active": 292, + "calories": 85.497, + "totalcalories": 2303.788, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-20", + "modified": 1697884856, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1155, + "distance": 1020.121, + "elevation": 0, + "soft": 1516, + "moderate": 1487, + "intense": 420, + "active": 1907, + "calories": 221.132, + "totalcalories": 2444.149, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-21", + "modified": 1697888004, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + } +] diff --git a/tests/components/withings/fixtures/devices.json b/tests/components/withings/fixtures/devices.json new file mode 100644 index 00000000000..9a2b7b81cf4 --- /dev/null +++ b/tests/components/withings/fixtures/devices.json @@ -0,0 +1,13 @@ +[ + { + "type": "Scale", + "battery": "high", + "model": "Body+", + "model_id": 5, + "timezone": "Europe/Amsterdam", + "first_session_date": null, + "last_session_date": 1693867179, + "deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d", + "hash_deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d" + } +] diff --git a/tests/components/withings/fixtures/empty_notify_list.json b/tests/components/withings/fixtures/empty_notify_list.json deleted file mode 100644 index c905c95e4cb..00000000000 --- a/tests/components/withings/fixtures/empty_notify_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "profiles": [] -} diff --git a/tests/components/withings/fixtures/get_device.json b/tests/components/withings/fixtures/get_device.json deleted file mode 100644 index 64bac3d4a19..00000000000 --- a/tests/components/withings/fixtures/get_device.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "devices": [ - { - "type": "Scale", - "battery": "high", - "model": "Body+", - "model_id": 5, - "timezone": "Europe/Amsterdam", - "first_session_date": null, - "last_session_date": 1693867179, - "deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d", - "hash_deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d" - } - ] -} diff --git a/tests/components/withings/fixtures/get_meas.json b/tests/components/withings/fixtures/get_meas.json deleted file mode 100644 index a7a2c09156c..00000000000 --- a/tests/components/withings/fixtures/get_meas.json +++ /dev/null @@ -1,278 +0,0 @@ -{ - "more": false, - "timezone": "UTC", - "updatetime": 1564617600, - "offset": 0, - "measuregrps": [ - { - "attrib": 0, - "category": 1, - "created": 1564660800, - "date": 1564660800, - "deviceid": "DEV_ID", - "grpid": 1, - "measures": [ - { - "type": 1, - "unit": 0, - "value": 70 - }, - { - "type": 8, - "unit": 0, - "value": 5 - }, - { - "type": 5, - "unit": 0, - "value": 60 - }, - { - "type": 76, - "unit": 0, - "value": 50 - }, - { - "type": 88, - "unit": 0, - "value": 10 - }, - { - "type": 4, - "unit": 0, - "value": 2 - }, - { - "type": 12, - "unit": 0, - "value": 40 - }, - { - "type": 71, - "unit": 0, - "value": 40 - }, - { - "type": 73, - "unit": 0, - "value": 20 - }, - { - "type": 6, - "unit": -3, - "value": 70 - }, - { - "type": 9, - "unit": 0, - "value": 70 - }, - { - "type": 10, - "unit": 0, - "value": 100 - }, - { - "type": 11, - "unit": 0, - "value": 60 - }, - { - "type": 54, - "unit": -2, - "value": 95 - }, - { - "type": 77, - "unit": -2, - "value": 95 - }, - { - "type": 91, - "unit": 0, - "value": 100 - } - ] - }, - { - "attrib": 0, - "category": 1, - "created": 1564657200, - "date": 1564657200, - "deviceid": "DEV_ID", - "grpid": 1, - "measures": [ - { - "type": 1, - "unit": 0, - "value": 71 - }, - { - "type": 8, - "unit": 0, - "value": 51 - }, - { - "type": 5, - "unit": 0, - "value": 61 - }, - { - "type": 76, - "unit": 0, - "value": 51 - }, - { - "type": 88, - "unit": 0, - "value": 11 - }, - { - "type": 4, - "unit": 0, - "value": 21 - }, - { - "type": 12, - "unit": 0, - "value": 41 - }, - { - "type": 71, - "unit": 0, - "value": 41 - }, - { - "type": 73, - "unit": 0, - "value": 21 - }, - { - "type": 6, - "unit": -3, - "value": 71 - }, - { - "type": 9, - "unit": 0, - "value": 71 - }, - { - "type": 10, - "unit": 0, - "value": 101 - }, - { - "type": 11, - "unit": 0, - "value": 61 - }, - { - "type": 54, - "unit": -2, - "value": 96 - }, - { - "type": 77, - "unit": -2, - "value": 96 - }, - { - "type": 91, - "unit": 0, - "value": 101 - } - ] - }, - { - "attrib": 1, - "category": 1, - "created": 1564664400, - "date": 1564664400, - "deviceid": "DEV_ID", - "grpid": 1, - "measures": [ - { - "type": 1, - "unit": 0, - "value": 71 - }, - { - "type": 8, - "unit": 0, - "value": 4 - }, - { - "type": 5, - "unit": 0, - "value": 40 - }, - { - "type": 76, - "unit": 0, - "value": 51 - }, - { - "type": 88, - "unit": 0, - "value": 11 - }, - { - "type": 4, - "unit": 0, - "value": 201 - }, - { - "type": 12, - "unit": 0, - "value": 41 - }, - { - "type": 71, - "unit": 0, - "value": 34 - }, - { - "type": 73, - "unit": 0, - "value": 21 - }, - { - "type": 6, - "unit": -3, - "value": 71 - }, - { - "type": 9, - "unit": 0, - "value": 71 - }, - { - "type": 10, - "unit": 0, - "value": 101 - }, - { - "type": 11, - "unit": 0, - "value": 61 - }, - { - "type": 54, - "unit": -2, - "value": 98 - }, - { - "type": 77, - "unit": -2, - "value": 96 - }, - { - "type": 91, - "unit": 0, - "value": 102 - } - ] - } - ] -} diff --git a/tests/components/withings/fixtures/get_sleep.json b/tests/components/withings/fixtures/get_sleep.json deleted file mode 100644 index fdc0e064709..00000000000 --- a/tests/components/withings/fixtures/get_sleep.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "more": false, - "offset": 0, - "series": [ - { - "timezone": "UTC", - "model": 32, - "startdate": 1548979200, - "enddate": 1548979200, - "date": 1548979200, - "modified": 12345, - "data": { - "breathing_disturbances_intensity": 110, - "deepsleepduration": 111, - "durationtosleep": 112, - "durationtowakeup": 113, - "hr_average": 114, - "hr_max": 115, - "hr_min": 116, - "lightsleepduration": 117, - "remsleepduration": 118, - "rr_average": 119, - "rr_max": 120, - "rr_min": 121, - "sleep_score": 122, - "snoring": 123, - "snoringepisodecount": 124, - "wakeupcount": 125, - "wakeupduration": 126 - } - }, - { - "timezone": "UTC", - "model": 32, - "startdate": 1548979200, - "enddate": 1548979200, - "date": 1548979200, - "modified": 12345, - "data": { - "breathing_disturbances_intensity": 210, - "deepsleepduration": 211, - "durationtosleep": 212, - "durationtowakeup": 213, - "hr_average": 214, - "hr_max": 215, - "hr_min": 216, - "lightsleepduration": 217, - "remsleepduration": 218, - "rr_average": 219, - "rr_max": 220, - "rr_min": 221, - "sleep_score": 222, - "snoring": 223, - "snoringepisodecount": 224, - "wakeupcount": 225, - "wakeupduration": 226 - } - } - ] -} diff --git a/tests/components/withings/fixtures/goals.json b/tests/components/withings/fixtures/goals.json new file mode 100644 index 00000000000..233ece9aac6 --- /dev/null +++ b/tests/components/withings/fixtures/goals.json @@ -0,0 +1,8 @@ +{ + "steps": 10000, + "sleep": 28800, + "weight": { + "value": 70500, + "unit": -3 + } +} diff --git a/tests/components/withings/fixtures/goals_1.json b/tests/components/withings/fixtures/goals_1.json new file mode 100644 index 00000000000..6b8046f0eb4 --- /dev/null +++ b/tests/components/withings/fixtures/goals_1.json @@ -0,0 +1,6 @@ +{ + "weight": { + "value": 70500, + "unit": -3 + } +} diff --git a/tests/components/withings/fixtures/measurements.json b/tests/components/withings/fixtures/measurements.json new file mode 100644 index 00000000000..3ed59a7c3f4 --- /dev/null +++ b/tests/components/withings/fixtures/measurements.json @@ -0,0 +1,307 @@ +[ + { + "grpid": 1, + "attrib": 0, + "date": 1564660800, + "created": 1564660800, + "modified": 1564660800, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "measures": [ + { + "type": 1, + "unit": 0, + "value": 70 + }, + { + "type": 8, + "unit": 0, + "value": 5 + }, + { + "type": 5, + "unit": 0, + "value": 60 + }, + { + "type": 76, + "unit": 0, + "value": 50 + }, + { + "type": 88, + "unit": 0, + "value": 10 + }, + { + "type": 4, + "unit": 0, + "value": 2 + }, + { + "type": 12, + "unit": 0, + "value": 40 + }, + { + "type": 71, + "unit": 0, + "value": 40 + }, + { + "type": 73, + "unit": 0, + "value": 20 + }, + { + "type": 6, + "unit": -3, + "value": 70 + }, + { + "type": 9, + "unit": 0, + "value": 70 + }, + { + "type": 10, + "unit": 0, + "value": 100 + }, + { + "type": 11, + "unit": 0, + "value": 60 + }, + { + "type": 54, + "unit": -2, + "value": 95 + }, + { + "type": 77, + "unit": -2, + "value": 95 + }, + { + "type": 91, + "unit": 0, + "value": 100 + }, + { + "type": 123, + "unit": 0, + "value": 100 + }, + { + "type": 155, + "unit": 0, + "value": 100 + }, + { + "type": 168, + "unit": 0, + "value": 100 + }, + { + "type": 169, + "unit": 0, + "value": 100 + } + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null + }, + { + "grpid": 1, + "attrib": 0, + "date": 1564657200, + "created": 1564657200, + "modified": 1564657200, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 8, + "unit": 0, + "value": 51 + }, + { + "type": 5, + "unit": 0, + "value": 61 + }, + { + "type": 76, + "unit": 0, + "value": 51 + }, + { + "type": 88, + "unit": 0, + "value": 11 + }, + { + "type": 4, + "unit": 0, + "value": 21 + }, + { + "type": 12, + "unit": 0, + "value": 41 + }, + { + "type": 71, + "unit": 0, + "value": 41 + }, + { + "type": 73, + "unit": 0, + "value": 21 + }, + { + "type": 6, + "unit": -3, + "value": 71 + }, + { + "type": 9, + "unit": 0, + "value": 71 + }, + { + "type": 10, + "unit": 0, + "value": 101 + }, + { + "type": 11, + "unit": 0, + "value": 61 + }, + { + "type": 54, + "unit": -2, + "value": 96 + }, + { + "type": 77, + "unit": -2, + "value": 96 + }, + { + "type": 91, + "unit": 0, + "value": 101 + } + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null + }, + { + "grpid": 1, + "attrib": 1, + "date": 1564664400, + "created": 1564664400, + "modified": 1564664400, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 8, + "unit": 0, + "value": 4 + }, + { + "type": 5, + "unit": 0, + "value": 40 + }, + { + "type": 76, + "unit": 0, + "value": 51 + }, + { + "type": 88, + "unit": 0, + "value": 11 + }, + { + "type": 4, + "unit": 0, + "value": 201 + }, + { + "type": 12, + "unit": 0, + "value": 41 + }, + { + "type": 71, + "unit": 0, + "value": 34 + }, + { + "type": 73, + "unit": 0, + "value": 21 + }, + { + "type": 6, + "unit": -3, + "value": 71 + }, + { + "type": 9, + "unit": 0, + "value": 71 + }, + { + "type": 10, + "unit": 0, + "value": 101 + }, + { + "type": 11, + "unit": 0, + "value": 61 + }, + { + "type": 54, + "unit": -2, + "value": 98 + }, + { + "type": 77, + "unit": -2, + "value": 96 + }, + { + "type": 91, + "unit": 0, + "value": 102 + } + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null + } +] diff --git a/tests/components/withings/fixtures/measurements_1.json b/tests/components/withings/fixtures/measurements_1.json new file mode 100644 index 00000000000..74148706bd7 --- /dev/null +++ b/tests/components/withings/fixtures/measurements_1.json @@ -0,0 +1,92 @@ +[ + { + "grpid": 1, + "attrib": 0, + "date": 1618605055, + "created": 1618605055, + "modified": 1618605055, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 5, + "unit": 0, + "value": 60 + }, + { + "type": 76, + "unit": 0, + "value": 50 + }, + { + "type": 88, + "unit": 0, + "value": 10 + }, + { + "type": 4, + "unit": 0, + "value": 2 + }, + { + "type": 12, + "unit": 0, + "value": 40 + }, + { + "type": 71, + "unit": 0, + "value": 40 + }, + { + "type": 73, + "unit": 0, + "value": 20 + }, + { + "type": 6, + "unit": -3, + "value": 70 + }, + { + "type": 9, + "unit": 0, + "value": 70 + }, + { + "type": 10, + "unit": 0, + "value": 100 + }, + { + "type": 11, + "unit": 0, + "value": 60 + }, + { + "type": 54, + "unit": -2, + "value": 95 + }, + { + "type": 77, + "unit": -2, + "value": 95 + }, + { + "type": 91, + "unit": 0, + "value": 100 + } + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null + } +] diff --git a/tests/components/withings/fixtures/notifications.json b/tests/components/withings/fixtures/notifications.json new file mode 100644 index 00000000000..8f4d49fde49 --- /dev/null +++ b/tests/components/withings/fixtures/notifications.json @@ -0,0 +1,20 @@ +[ + { + "appli": 50, + "callbackurl": "https://not.my.callback/url", + "expires": 2147483647, + "comment": null + }, + { + "appli": 50, + "callbackurl": "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + }, + { + "appli": 51, + "callbackurl": "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + } +] diff --git a/tests/components/withings/fixtures/notify_list.json b/tests/components/withings/fixtures/notify_list.json deleted file mode 100644 index 5b368a5c979..00000000000 --- a/tests/components/withings/fixtures/notify_list.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "profiles": [ - { - "appli": 50, - "callbackurl": "https://not.my.callback/url", - "expires": 2147483647, - "comment": null - }, - { - "appli": 50, - "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", - "expires": 2147483647, - "comment": null - }, - { - "appli": 51, - "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", - "expires": 2147483647, - "comment": null - } - ] -} diff --git a/tests/components/withings/fixtures/sleep_summaries.json b/tests/components/withings/fixtures/sleep_summaries.json new file mode 100644 index 00000000000..1bcfcfcc1d2 --- /dev/null +++ b/tests/components/withings/fixtures/sleep_summaries.json @@ -0,0 +1,197 @@ +[ + { + "id": 2081804182, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618691453, + "enddate": 1618713173, + "date": "2021-04-18", + "data": { + "wakeupduration": 3060, + "wakeupcount": 1, + "durationtosleep": 540, + "remsleepduration": 2400, + "durationtowakeup": 1140, + "total_sleep_time": 18660, + "sleep_efficiency": 0.86, + "sleep_latency": 540, + "wakeup_latency": 1140, + "waso": 1380, + "nb_rem_episodes": 1, + "out_of_bed_count": 0, + "lightsleepduration": 10440, + "deepsleepduration": 5820, + "hr_average": 103, + "hr_min": 70, + "hr_max": 120, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 9, + "snoring": 1080, + "snoringepisodecount": 18, + "sleep_score": 37, + "apnea_hypopnea_index": 9 + }, + "created": 1620237476, + "modified": 1620237476 + }, + { + "id": 2081804265, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618605055, + "enddate": 1618636975, + "date": "2021-04-17", + "data": { + "wakeupduration": 2520, + "wakeupcount": 3, + "durationtosleep": 900, + "remsleepduration": 6840, + "durationtowakeup": 420, + "total_sleep_time": 26880, + "sleep_efficiency": 0.91, + "sleep_latency": 900, + "wakeup_latency": 420, + "waso": 1200, + "nb_rem_episodes": 2, + "out_of_bed_count": 0, + "lightsleepduration": 12840, + "deepsleepduration": 7200, + "hr_average": 85, + "hr_min": 50, + "hr_max": 120, + "rr_average": 16, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 14, + "snoring": 1140, + "snoringepisodecount": 19, + "sleep_score": 90, + "apnea_hypopnea_index": 14 + }, + "created": 1620237480, + "modified": 1620237479 + }, + { + "id": 2081804358, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618518658, + "enddate": 1618548058, + "date": "2021-04-16", + "data": { + "wakeupduration": 4080, + "wakeupcount": 1, + "durationtosleep": 840, + "remsleepduration": 2040, + "durationtowakeup": 1560, + "total_sleep_time": 16860, + "sleep_efficiency": 0.81, + "sleep_latency": 840, + "wakeup_latency": 1560, + "waso": 1680, + "nb_rem_episodes": 2, + "out_of_bed_count": 0, + "lightsleepduration": 11100, + "deepsleepduration": 3720, + "hr_average": 65, + "hr_min": 50, + "hr_max": 91, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": -1, + "snoring": 1020, + "snoringepisodecount": 17, + "sleep_score": 20, + "apnea_hypopnea_index": -1 + }, + "created": 1620237484, + "modified": 1620237484 + }, + { + "id": 2081804405, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618432203, + "enddate": 1618453143, + "date": "2021-04-15", + "data": { + "wakeupduration": 4080, + "wakeupcount": 1, + "durationtosleep": 840, + "remsleepduration": 2040, + "durationtowakeup": 1560, + "total_sleep_time": 16860, + "sleep_efficiency": 0.81, + "sleep_latency": 840, + "wakeup_latency": 1560, + "waso": 1680, + "nb_rem_episodes": 2, + "out_of_bed_count": 0, + "lightsleepduration": 11100, + "deepsleepduration": 3720, + "hr_average": 65, + "hr_min": 50, + "hr_max": 91, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": -1, + "snoring": 1020, + "snoringepisodecount": 17, + "sleep_score": 20, + "apnea_hypopnea_index": -1 + }, + "created": 1620237486, + "modified": 1620237486 + }, + { + "id": 2081804490, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618345805, + "enddate": 1618373504, + "date": "2021-04-14", + "data": { + "wakeupduration": 3600, + "wakeupcount": 2, + "durationtosleep": 780, + "remsleepduration": 3960, + "durationtowakeup": 300, + "total_sleep_time": 22680, + "sleep_efficiency": 0.86, + "sleep_latency": 780, + "wakeup_latency": 300, + "waso": 3939, + "nb_rem_episodes": 4, + "out_of_bed_count": 3, + "lightsleepduration": 12960, + "deepsleepduration": 5760, + "hr_average": 98, + "hr_min": 70, + "hr_max": 120, + "rr_average": 13, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 29, + "snoring": 960, + "snoringepisodecount": 16, + "sleep_score": 62, + "apnea_hypopnea_index": 29 + }, + "created": 1620237490, + "modified": 1620237489 + } +] diff --git a/tests/components/withings/fixtures/workouts.json b/tests/components/withings/fixtures/workouts.json new file mode 100644 index 00000000000..d5edcc75580 --- /dev/null +++ b/tests/components/withings/fixtures/workouts.json @@ -0,0 +1,327 @@ +[ + { + "id": 3661300277, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1693336011, + "enddate": 1693336513, + "date": "2023-08-29", + "deviceid": null, + "data": { + "calories": 47, + "intensity": 30, + "manual_distance": 60, + "manual_calories": 70, + "hr_average": 80, + "hr_min": 70, + "hr_max": 80, + "hr_zone_0": 100, + "hr_zone_1": 200, + "hr_zone_2": 300, + "hr_zone_3": 400, + "pause_duration": 80, + "steps": 779, + "distance": 680, + "elevation": 10, + "algo_pause_duration": null, + "spo2_average": 15 + }, + "modified": 1693481873 + }, + { + "id": 3661300290, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1693469307, + "enddate": 1693469924, + "date": "2023-08-31", + "deviceid": null, + "data": { + "algo_pause_duration": null + }, + "modified": 1693481873 + }, + { + "id": 3661300269, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1691164839, + "enddate": 1691165719, + "date": "2023-08-04", + "deviceid": null, + "data": { + "calories": 82, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1450, + "distance": 1294, + "elevation": 18, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1693481873 + }, + { + "id": 3743596080, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1695425635, + "enddate": 1695426661, + "date": "2023-09-23", + "deviceid": null, + "data": { + "calories": 97, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1650, + "distance": 1405, + "elevation": 19, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596073, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1694715649, + "enddate": 1694716306, + "date": "2023-09-14", + "deviceid": null, + "data": { + "calories": 62, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1076, + "distance": 917, + "elevation": 15, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596085, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1695426953, + "enddate": 1695427093, + "date": "2023-09-23", + "deviceid": null, + "data": { + "calories": 13, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 216, + "distance": 185, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596072, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1694713351, + "enddate": 1694715327, + "date": "2023-09-14", + "deviceid": null, + "data": { + "calories": 187, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 3339, + "distance": 2908, + "elevation": 49, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3752609171, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696835569, + "enddate": 1696835767, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 18, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 291, + "distance": 261, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609178, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696844383, + "enddate": 1696844638, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 24, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 267, + "distance": 232, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609174, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696842803, + "enddate": 1696843032, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 21, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 403, + "distance": 359, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609174, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696842803, + "enddate": 1696843032, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 21, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 403, + "distance": 359, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + } +] diff --git a/tests/components/withings/snapshots/test_calendar.ambr b/tests/components/withings/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..045b4216a2f --- /dev/null +++ b/tests/components/withings/snapshots/test_calendar.ambr @@ -0,0 +1,167 @@ +# serializer version: 1 +# name: test_api_calendar + list([ + dict({ + 'entity_id': 'calendar.henk_workouts', + 'name': 'henk Workouts', + }), + ]) +# --- +# name: test_api_events + list([ + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-29T12:15:13-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-29T12:06:51-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-31T01:18:44-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-31T01:08:27-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-04T09:15:19-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-04T09:00:39-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-22T16:51:01-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-22T16:33:55-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-14T11:31:46-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-14T11:20:49-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-22T16:58:13-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-22T16:55:53-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-14T11:15:27-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-14T10:42:31-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T00:16:07-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T00:12:49-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:43:58-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:39:43-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:17:12-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:13:23-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:17:12-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:13:23-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + ]) +# --- diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f9b4a1d9bba --- /dev/null +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_diagnostics_cloudhook_instance + dict({ + 'has_cloudhooks': True, + 'has_valid_external_webhook_url': True, + 'received_activity_data': False, + 'received_measurements': list([ + 1, + 8, + 5, + 76, + 88, + 4, + 12, + 71, + 73, + 6, + 9, + 10, + 11, + 54, + 77, + 91, + 123, + 155, + 168, + 169, + ]), + 'received_sleep_data': True, + 'received_workout_data': True, + 'webhooks_connected': True, + }) +# --- +# name: test_diagnostics_polling_instance + dict({ + 'has_cloudhooks': False, + 'has_valid_external_webhook_url': False, + 'received_activity_data': False, + 'received_measurements': list([ + 1, + 8, + 5, + 76, + 88, + 4, + 12, + 71, + 73, + 6, + 9, + 10, + 11, + 54, + 77, + 91, + 123, + 155, + 168, + 169, + ]), + 'received_sleep_data': True, + 'received_workout_data': True, + 'webhooks_connected': False, + }) +# --- +# name: test_diagnostics_webhook_instance + dict({ + 'has_cloudhooks': False, + 'has_valid_external_webhook_url': True, + 'received_activity_data': False, + 'received_measurements': list([ + 1, + 8, + 5, + 76, + 88, + 4, + 12, + 71, + 73, + 6, + 9, + 10, + 11, + 54, + 77, + 91, + 123, + 155, + 168, + 169, + ]), + 'received_sleep_data': True, + 'received_workout_data': True, + 'webhooks_connected': True, + }) +# --- diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 9733880b03a..59d9b470247 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -1,20 +1,214 @@ # serializer version: 1 -# name: test_all_entities +# name: test_all_entities[sensor.henk_active_calories_burnt_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Active calories burnt today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'calories', + }), + 'context': , + 'entity_id': 'sensor.henk_active_calories_burnt_today', + 'last_changed': , + 'last_updated': , + 'state': '221.132', + }) +# --- +# name: test_all_entities[sensor.henk_active_time_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Active time today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_active_time_today', + 'last_changed': , + 'last_updated': , + 'state': '1907', + }) +# --- +# name: test_all_entities[sensor.henk_average_heart_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Average heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_average_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '103', + }) +# --- +# name: test_all_entities[sensor.henk_average_respiratory_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Average respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_average_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '14', + }) +# --- +# name: test_all_entities[sensor.henk_body_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Body temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_body_temperature', + 'last_changed': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[sensor.henk_bone_mass] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', - 'friendly_name': 'henk Weight', + 'friendly_name': 'henk Bone mass', + 'icon': 'mdi:bone', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_weight', + 'entity_id': 'sensor.henk_bone_mass', 'last_changed': , 'last_updated': , - 'state': '70.0', + 'state': '10', }) # --- -# name: test_all_entities.1 +# name: test_all_entities[sensor.henk_breathing_disturbances_intensity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Breathing disturbances intensity', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_breathing_disturbances_intensity', + 'last_changed': , + 'last_updated': , + 'state': '9', + }) +# --- +# name: test_all_entities[sensor.henk_calories_burnt_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Calories burnt last workout', + 'unit_of_measurement': 'calories', + }), + 'context': , + 'entity_id': 'sensor.henk_calories_burnt_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_all_entities[sensor.henk_deep_sleep] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Deep sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_deep_sleep', + 'last_changed': , + 'last_updated': , + 'state': '5820', + }) +# --- +# name: test_all_entities[sensor.henk_diastolic_blood_pressure] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Diastolic blood pressure', + 'state_class': , + 'unit_of_measurement': 'mmhg', + }), + 'context': , + 'entity_id': 'sensor.henk_diastolic_blood_pressure', + 'last_changed': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_all_entities[sensor.henk_distance_travelled_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Distance travelled last workout', + 'icon': 'mdi:map-marker-distance', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_distance_travelled_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '232', + }) +# --- +# name: test_all_entities[sensor.henk_distance_travelled_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Distance travelled today', + 'icon': 'mdi:map-marker-distance', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_distance_travelled_today', + 'last_changed': , + 'last_updated': , + 'state': '1020.121', + }) +# --- +# name: test_all_entities[sensor.henk_extracellular_water] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Extracellular water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_extracellular_water', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat free mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_free_mass', + 'last_changed': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -26,38 +220,54 @@ 'entity_id': 'sensor.henk_fat_mass', 'last_changed': , 'last_updated': , - 'state': '5.0', + 'state': '5', }) # --- -# name: test_all_entities.10 +# name: test_all_entities[sensor.henk_fat_ratio] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Diastolic blood pressure', + 'friendly_name': 'henk Fat ratio', 'state_class': , - 'unit_of_measurement': 'mmhg', + 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.henk_diastolic_blood_pressure', + 'entity_id': 'sensor.henk_fat_ratio', 'last_changed': , 'last_updated': , - 'state': '70.0', + 'state': '0.07', }) # --- -# name: test_all_entities.11 +# name: test_all_entities[sensor.henk_floors_climbed_last_workout] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Systolic blood pressure', - 'state_class': , - 'unit_of_measurement': 'mmhg', + 'friendly_name': 'henk Floors climbed last workout', + 'icon': 'mdi:stairs-up', + 'unit_of_measurement': 'floors', }), 'context': , - 'entity_id': 'sensor.henk_systolic_blood_pressure', + 'entity_id': 'sensor.henk_floors_climbed_last_workout', 'last_changed': , 'last_updated': , - 'state': '100.0', + 'state': '4', }) # --- -# name: test_all_entities.12 +# name: test_all_entities[sensor.henk_floors_climbed_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Floors climbed today', + 'icon': 'mdi:stairs-up', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'floors', + }), + 'context': , + 'entity_id': 'sensor.henk_floors_climbed_today', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.henk_heart_pulse] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Heart pulse', @@ -69,24 +279,25 @@ 'entity_id': 'sensor.henk_heart_pulse', 'last_changed': , 'last_updated': , - 'state': '60.0', + 'state': '60', }) # --- -# name: test_all_entities.13 +# name: test_all_entities[sensor.henk_height] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk SpO2', + 'device_class': 'distance', + 'friendly_name': 'henk Height', 'state_class': , - 'unit_of_measurement': '%', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_spo2', + 'entity_id': 'sensor.henk_height', 'last_changed': , 'last_updated': , - 'state': '0.95', + 'state': '2', }) # --- -# name: test_all_entities.14 +# name: test_all_entities[sensor.henk_hydration] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -102,143 +313,129 @@ 'state': '0.95', }) # --- -# name: test_all_entities.15 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'speed', - 'friendly_name': 'henk Pulse wave velocity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_pulse_wave_velocity', - 'last_changed': , - 'last_updated': , - 'state': '100.0', - }) -# --- -# name: test_all_entities.16 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Breathing disturbances intensity', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.henk_breathing_disturbances_intensity', - 'last_changed': , - 'last_updated': , - 'state': '160.0', - }) -# --- -# name: test_all_entities.17 +# name: test_all_entities[sensor.henk_intense_activity_today] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'henk Deep sleep', - 'icon': 'mdi:sleep', - 'state_class': , + 'friendly_name': 'henk Intense activity today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_deep_sleep', + 'entity_id': 'sensor.henk_intense_activity_today', 'last_changed': , 'last_updated': , - 'state': '322', + 'state': '420', }) # --- -# name: test_all_entities.18 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Time to sleep', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_time_to_sleep', - 'last_changed': , - 'last_updated': , - 'state': '162.0', - }) -# --- -# name: test_all_entities.19 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Time to wakeup', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_time_to_wakeup', - 'last_changed': , - 'last_updated': , - 'state': '163.0', - }) -# --- -# name: test_all_entities.2 +# name: test_all_entities[sensor.henk_intracellular_water] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', - 'friendly_name': 'henk Fat free mass', + 'friendly_name': 'henk Intracellular water', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_fat_free_mass', + 'entity_id': 'sensor.henk_intracellular_water', 'last_changed': , 'last_updated': , - 'state': '60.0', + 'state': '100', }) # --- -# name: test_all_entities.20 +# name: test_all_entities[sensor.henk_last_workout_duration] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Average heart rate', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', + 'device_class': 'duration', + 'friendly_name': 'henk Last workout duration', + 'icon': 'mdi:timer', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_average_heart_rate', + 'entity_id': 'sensor.henk_last_workout_duration', 'last_changed': , 'last_updated': , - 'state': '164.0', + 'state': '255.0', }) # --- -# name: test_all_entities.21 +# name: test_all_entities[sensor.henk_last_workout_intensity] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Maximum heart rate', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', + 'friendly_name': 'henk Last workout intensity', }), 'context': , - 'entity_id': 'sensor.henk_maximum_heart_rate', + 'entity_id': 'sensor.henk_last_workout_intensity', 'last_changed': , 'last_updated': , - 'state': '165.0', + 'state': '30', }) # --- -# name: test_all_entities.22 +# name: test_all_entities[sensor.henk_last_workout_type] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Minimum heart rate', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', + 'device_class': 'enum', + 'friendly_name': 'henk Last workout type', + 'options': list([ + 'walk', + 'run', + 'hiking', + 'skating', + 'bmx', + 'bicycling', + 'swimming', + 'surfing', + 'kitesurfing', + 'windsurfing', + 'bodyboard', + 'tennis', + 'table_tennis', + 'squash', + 'badminton', + 'lift_weights', + 'calisthenics', + 'elliptical', + 'pilates', + 'basket_ball', + 'soccer', + 'football', + 'rugby', + 'volley_ball', + 'waterpolo', + 'horse_riding', + 'golf', + 'yoga', + 'dancing', + 'boxing', + 'fencing', + 'wrestling', + 'martial_arts', + 'skiing', + 'snowboarding', + 'other', + 'no_activity', + 'rowing', + 'zumba', + 'baseball', + 'handball', + 'hockey', + 'ice_hockey', + 'climbing', + 'ice_skating', + 'multi_sport', + 'indoor_walk', + 'indoor_running', + 'indoor_cycling', + ]), }), 'context': , - 'entity_id': 'sensor.henk_minimum_heart_rate', + 'entity_id': 'sensor.henk_last_workout_type', 'last_changed': , 'last_updated': , - 'state': '166.0', + 'state': 'walk', }) # --- -# name: test_all_entities.23 +# name: test_all_entities[sensor.henk_light_sleep] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -251,10 +448,129 @@ 'entity_id': 'sensor.henk_light_sleep', 'last_changed': , 'last_updated': , - 'state': '334', + 'state': '10440', }) # --- -# name: test_all_entities.24 +# name: test_all_entities[sensor.henk_maximum_heart_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Maximum heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_maximum_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_all_entities[sensor.henk_maximum_respiratory_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Maximum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_maximum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[sensor.henk_minimum_heart_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Minimum heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_minimum_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_all_entities[sensor.henk_minimum_respiratory_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Minimum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_minimum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[sensor.henk_moderate_activity_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Moderate activity today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_moderate_activity_today', + 'last_changed': , + 'last_updated': , + 'state': '1487', + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Muscle mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_muscle_mass', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.henk_pause_during_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Pause during last workout', + 'icon': 'mdi:timer-pause', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_pause_during_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.henk_pulse_wave_velocity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'henk Pulse wave velocity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_pulse_wave_velocity', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_rem_sleep] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -267,52 +583,41 @@ 'entity_id': 'sensor.henk_rem_sleep', 'last_changed': , 'last_updated': , - 'state': '336', + 'state': '2400', }) # --- -# name: test_all_entities.25 +# name: test_all_entities[sensor.henk_skin_temperature] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Average respiratory rate', + 'device_class': 'temperature', + 'friendly_name': 'henk Skin temperature', 'state_class': , - 'unit_of_measurement': 'br/min', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_average_respiratory_rate', + 'entity_id': 'sensor.henk_skin_temperature', 'last_changed': , 'last_updated': , - 'state': '169.0', + 'state': '20', }) # --- -# name: test_all_entities.26 +# name: test_all_entities[sensor.henk_sleep_goal] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Maximum respiratory rate', + 'device_class': 'duration', + 'friendly_name': 'henk Sleep goal', + 'icon': 'mdi:bed-clock', 'state_class': , - 'unit_of_measurement': 'br/min', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_maximum_respiratory_rate', + 'entity_id': 'sensor.henk_sleep_goal', 'last_changed': , 'last_updated': , - 'state': '170.0', + 'state': '28800', }) # --- -# name: test_all_entities.27 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Minimum respiratory rate', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.henk_minimum_respiratory_rate', - 'last_changed': , - 'last_updated': , - 'state': '171.0', - }) -# --- -# name: test_all_entities.28 +# name: test_all_entities[sensor.henk_sleep_score] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Sleep score', @@ -324,10 +629,10 @@ 'entity_id': 'sensor.henk_sleep_score', 'last_changed': , 'last_updated': , - 'state': '222', + 'state': '37', }) # --- -# name: test_all_entities.29 +# name: test_all_entities[sensor.henk_snoring] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Snoring', @@ -337,25 +642,10 @@ 'entity_id': 'sensor.henk_snoring', 'last_changed': , 'last_updated': , - 'state': '173.0', + 'state': '1080', }) # --- -# name: test_all_entities.3 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'weight', - 'friendly_name': 'henk Muscle mass', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_muscle_mass', - 'last_changed': , - 'last_updated': , - 'state': '50.0', - }) -# --- -# name: test_all_entities.30 +# name: test_all_entities[sensor.henk_snoring_episode_count] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Snoring episode count', @@ -365,10 +655,173 @@ 'entity_id': 'sensor.henk_snoring_episode_count', 'last_changed': , 'last_updated': , - 'state': '348', + 'state': '18', }) # --- -# name: test_all_entities.31 +# name: test_all_entities[sensor.henk_soft_activity_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Soft activity today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_soft_activity_today', + 'last_changed': , + 'last_updated': , + 'state': '1516', + }) +# --- +# name: test_all_entities[sensor.henk_spo2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk SpO2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_spo2', + 'last_changed': , + 'last_updated': , + 'state': '0.95', + }) +# --- +# name: test_all_entities[sensor.henk_step_goal] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Step goal', + 'icon': 'mdi:shoe-print', + 'state_class': , + 'unit_of_measurement': 'steps', + }), + 'context': , + 'entity_id': 'sensor.henk_step_goal', + 'last_changed': , + 'last_updated': , + 'state': '10000', + }) +# --- +# name: test_all_entities[sensor.henk_steps_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Steps today', + 'icon': 'mdi:shoe-print', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'steps', + }), + 'context': , + 'entity_id': 'sensor.henk_steps_today', + 'last_changed': , + 'last_updated': , + 'state': '1155', + }) +# --- +# name: test_all_entities[sensor.henk_systolic_blood_pressure] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Systolic blood pressure', + 'state_class': , + 'unit_of_measurement': 'mmhg', + }), + 'context': , + 'entity_id': 'sensor.henk_systolic_blood_pressure', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_temperature', + 'last_changed': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[sensor.henk_time_to_sleep] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_sleep', + 'last_changed': , + 'last_updated': , + 'state': '540', + }) +# --- +# name: test_all_entities[sensor.henk_time_to_wakeup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to wakeup', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_wakeup', + 'last_changed': , + 'last_updated': , + 'state': '1140', + }) +# --- +# name: test_all_entities[sensor.henk_total_calories_burnt_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Total calories burnt today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'calories', + }), + 'context': , + 'entity_id': 'sensor.henk_total_calories_burnt_today', + 'last_changed': , + 'last_updated': , + 'state': '2444.149', + }) +# --- +# name: test_all_entities[sensor.henk_vascular_age] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Vascular age', + }), + 'context': , + 'entity_id': 'sensor.henk_vascular_age', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_vo2_max] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk VO2 max', + 'state_class': , + 'unit_of_measurement': 'ml/min/kg', + }), + 'context': , + 'entity_id': 'sensor.henk_vo2_max', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_wakeup_count] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Wakeup count', @@ -380,10 +833,10 @@ 'entity_id': 'sensor.henk_wakeup_count', 'last_changed': , 'last_updated': , - 'state': '350', + 'state': '1', }) # --- -# name: test_all_entities.32 +# name: test_all_entities[sensor.henk_wakeup_time] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -396,859 +849,36 @@ 'entity_id': 'sensor.henk_wakeup_time', 'last_changed': , 'last_updated': , - 'state': '176.0', + 'state': '3060', }) # --- -# name: test_all_entities.33 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_breathing_disturbances_intensity henk', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_breathing_disturbances_intensity_henk', - 'last_changed': , - 'last_updated': , - 'state': '160.0', - }) -# --- -# name: test_all_entities.34 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_deep_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep', - 'original_name': 'Withings sleep_deep_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_deep_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.35 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_deep_duration_seconds henk', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_deep_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '322', - }) -# --- -# name: test_all_entities.36 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_tosleep_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep', - 'original_name': 'Withings sleep_tosleep_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.37 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_tosleep_duration_seconds henk', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_tosleep_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '162.0', - }) -# --- -# name: test_all_entities.38 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_towakeup_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep-off', - 'original_name': 'Withings sleep_towakeup_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.39 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_towakeup_duration_seconds henk', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_towakeup_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '163.0', - }) -# --- -# name: test_all_entities.4 +# name: test_all_entities[sensor.henk_weight] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', - 'friendly_name': 'henk Bone mass', + 'friendly_name': 'henk Weight', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_bone_mass', + 'entity_id': 'sensor.henk_weight', 'last_changed': , 'last_updated': , - 'state': '10.0', + 'state': '70', }) # --- -# name: test_all_entities.40 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_heart_rate_average_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:heart-pulse', - 'original_name': 'Withings sleep_heart_rate_average_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_heart_rate_average_bpm', - 'unit_of_measurement': 'bpm', - }) -# --- -# name: test_all_entities.41 +# name: test_all_entities[sensor.henk_weight_goal] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_heart_rate_average_bpm henk', - 'icon': 'mdi:heart-pulse', + 'device_class': 'weight', + 'friendly_name': 'henk Weight goal', 'state_class': , - 'unit_of_measurement': 'bpm', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.withings_sleep_heart_rate_average_bpm_henk', + 'entity_id': 'sensor.henk_weight_goal', 'last_changed': , 'last_updated': , - 'state': '164.0', - }) -# --- -# name: test_all_entities.42 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_heart_rate_max_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:heart-pulse', - 'original_name': 'Withings sleep_heart_rate_max_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_heart_rate_max_bpm', - 'unit_of_measurement': 'bpm', - }) -# --- -# name: test_all_entities.43 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_heart_rate_max_bpm henk', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_heart_rate_max_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '165.0', - }) -# --- -# name: test_all_entities.44 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_heart_rate_min_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:heart-pulse', - 'original_name': 'Withings sleep_heart_rate_min_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_heart_rate_min_bpm', - 'unit_of_measurement': 'bpm', - }) -# --- -# name: test_all_entities.45 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_heart_rate_min_bpm henk', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_heart_rate_min_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '166.0', - }) -# --- -# name: test_all_entities.46 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_light_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep', - 'original_name': 'Withings sleep_light_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_light_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.47 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_light_duration_seconds henk', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_light_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '334', - }) -# --- -# name: test_all_entities.48 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_rem_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep', - 'original_name': 'Withings sleep_rem_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_rem_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.49 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_rem_duration_seconds henk', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_rem_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '336', - }) -# --- -# name: test_all_entities.5 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'henk Height', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_height', - 'last_changed': , - 'last_updated': , - 'state': '2.0', - }) -# --- -# name: test_all_entities.50 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_respiratory_average_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_respiratory_average_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_respiratory_average_bpm', - 'unit_of_measurement': 'br/min', - }) -# --- -# name: test_all_entities.51 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_respiratory_average_bpm henk', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_respiratory_average_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '169.0', - }) -# --- -# name: test_all_entities.52 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_respiratory_max_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_respiratory_max_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_respiratory_max_bpm', - 'unit_of_measurement': 'br/min', - }) -# --- -# name: test_all_entities.53 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_respiratory_max_bpm henk', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_respiratory_max_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '170.0', - }) -# --- -# name: test_all_entities.54 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_respiratory_min_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_respiratory_min_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_respiratory_min_bpm', - 'unit_of_measurement': 'br/min', - }) -# --- -# name: test_all_entities.55 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_respiratory_min_bpm henk', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_respiratory_min_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '171.0', - }) -# --- -# name: test_all_entities.56 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_score_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:medal', - 'original_name': 'Withings sleep_score henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_score', - 'unit_of_measurement': 'points', - }) -# --- -# name: test_all_entities.57 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_score henk', - 'icon': 'mdi:medal', - 'state_class': , - 'unit_of_measurement': 'points', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_score_henk', - 'last_changed': , - 'last_updated': , - 'state': '222', - }) -# --- -# name: test_all_entities.58 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_snoring_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_snoring henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_snoring', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities.59 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_snoring henk', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_snoring_henk', - 'last_changed': , - 'last_updated': , - 'state': '173.0', - }) -# --- -# name: test_all_entities.6 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'henk Temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_temperature', - 'last_changed': , - 'last_updated': , - 'state': '40.0', - }) -# --- -# name: test_all_entities.60 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_snoring_eposode_count_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_snoring_eposode_count henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_snoring_eposode_count', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities.61 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_snoring_eposode_count henk', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_snoring_eposode_count_henk', - 'last_changed': , - 'last_updated': , - 'state': '348', - }) -# --- -# name: test_all_entities.62 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_wakeup_count_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:sleep-off', - 'original_name': 'Withings sleep_wakeup_count henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_wakeup_count', - 'unit_of_measurement': 'times', - }) -# --- -# name: test_all_entities.63 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_wakeup_count henk', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': 'times', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_wakeup_count_henk', - 'last_changed': , - 'last_updated': , - 'state': '350', - }) -# --- -# name: test_all_entities.64 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_wakeup_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep-off', - 'original_name': 'Withings sleep_wakeup_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.65 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_wakeup_duration_seconds henk', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_wakeup_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '176.0', - }) -# --- -# name: test_all_entities.7 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'henk Body temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_body_temperature', - 'last_changed': , - 'last_updated': , - 'state': '40.0', - }) -# --- -# name: test_all_entities.8 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'henk Skin temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_skin_temperature', - 'last_changed': , - 'last_updated': , - 'state': '20.0', - }) -# --- -# name: test_all_entities.9 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Fat ratio', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.henk_fat_ratio', - 'last_changed': , - 'last_updated': , - 'state': '0.07', + 'state': '70.5', }) # --- diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index aa757486f86..c93c4522684 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -2,9 +2,9 @@ from unittest.mock import AsyncMock from aiohttp.client_exceptions import ClientResponseError +from aiowithings import NotificationCategory from freezegun.api import FrozenDateTimeFactory import pytest -from withings_api.common import NotifyAppli from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -22,6 +22,7 @@ async def test_binary_sensor( webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test binary sensor.""" await setup_integration(hass, webhook_config_entry) @@ -31,12 +32,12 @@ async def test_binary_sensor( entity_id = "binary_sensor.henk_in_bed" - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id) is None resp = await call_webhook( hass, WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + {"userid": USER_ID, "appli": NotificationCategory.IN_BED}, client, ) assert resp.message_code == 0 @@ -46,13 +47,22 @@ async def test_binary_sensor( resp = await call_webhook( hass, WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_OUT}, + {"userid": USER_ID, "appli": NotificationCategory.OUT_BED}, client, ) assert resp.message_code == 0 await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + await hass.config_entries.async_reload(webhook_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert ( + "Platform withings does not generate unique IDs. ID withings_12345_in_bed " + "already exists - ignoring binary_sensor.henk_in_bed" not in caplog.text + ) + async def test_polling_binary_sensor( hass: HomeAssistant, @@ -67,12 +77,12 @@ async def test_polling_binary_sensor( entity_id = "binary_sensor.henk_in_bed" - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id) is None with pytest.raises(ClientResponseError): await call_webhook( hass, WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + {"userid": USER_ID, "appli": NotificationCategory.IN_BED}, client, ) diff --git a/tests/components/withings/test_calendar.py b/tests/components/withings/test_calendar.py new file mode 100644 index 00000000000..227f65473fc --- /dev/null +++ b/tests/components/withings/test_calendar.py @@ -0,0 +1,85 @@ +"""Tests for the Withings calendar.""" +from datetime import date, timedelta +from http import HTTPStatus +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import load_workout_fixture + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.withings import setup_integration +from tests.typing import ClientSessionGenerator + + +async def test_api_calendar( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the API returns the calendar.""" + await setup_integration(hass, polling_config_entry, False) + + client = await hass_client() + response = await client.get("/api/calendars") + assert response.status == HTTPStatus.OK + data = await response.json() + assert data == snapshot + + +async def test_api_events( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the Withings calendar view.""" + await setup_integration(hass, polling_config_entry, False) + + client = await hass_client() + response = await client.get( + "/api/calendars/calendar.henk_workouts?start=2023-08-01&end=2023-11-01" + ) + assert withings.get_workouts_in_period.called == 1 + assert withings.get_workouts_in_period.call_args_list[1].args == ( + date(2023, 8, 1), + date(2023, 11, 1), + ) + assert response.status == HTTPStatus.OK + events = await response.json() + assert events == snapshot + + +async def test_calendar_created_when_workouts_available( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the calendar is only created when workouts are available.""" + withings.get_workouts_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("calendar.henk_workouts") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("calendar.henk_workouts") is None + + withings.get_workouts_in_period.return_value = load_workout_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("calendar.henk_workouts") diff --git a/tests/components/withings/test_diagnostics.py b/tests/components/withings/test_diagnostics.py new file mode 100644 index 00000000000..bb5c93e1f09 --- /dev/null +++ b/tests/components/withings/test_diagnostics.py @@ -0,0 +1,80 @@ +"""Tests for the diagnostics data provided by the Withings integration.""" +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.components.withings import prepare_webhook_setup, setup_integration +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics_polling_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, polling_config_entry, False) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, polling_config_entry) + == snapshot + ) + + +async def test_diagnostics_webhook_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, webhook_config_entry) + == snapshot + ) + + +async def test_diagnostics_cloudhook_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test diagnostics.""" + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, webhook_config_entry) + == snapshot + ) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index ab83bbcfb36..3f20791ac4d 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -4,11 +4,15 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import urlparse +from aiohttp.hdrs import METH_HEAD +from aiowithings import ( + NotificationCategory, + WithingsAuthenticationFailedError, + WithingsUnauthorizedError, +) from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol -from withings_api import NotifyListResponse -from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException from homeassistant import config_entries from homeassistant.components.cloud import CloudNotAvailable @@ -26,7 +30,6 @@ from tests.common import ( MockConfigEntry, async_fire_time_changed, async_mock_cloud_connection_status, - load_json_object_fixture, ) from tests.components.cloud import mock_cloud from tests.typing import ClientSessionGenerator @@ -117,71 +120,69 @@ async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, webhook_config_entry: MockConfigEntry, - hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" await setup_integration(hass, webhook_config_entry) - await hass_client_no_auth() await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() - assert withings.async_notify_subscribe.call_count == 6 + assert withings.subscribe_notification.call_count == 6 - webhook_url = "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + webhook_url = "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" - withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) - withings.async_notify_subscribe.assert_any_call( - webhook_url, NotifyAppli.CIRCULATORY + withings.subscribe_notification.assert_any_call( + webhook_url, NotificationCategory.WEIGHT + ) + withings.subscribe_notification.assert_any_call( + webhook_url, NotificationCategory.PRESSURE + ) + withings.subscribe_notification.assert_any_call( + webhook_url, NotificationCategory.ACTIVITY + ) + withings.subscribe_notification.assert_any_call( + webhook_url, NotificationCategory.SLEEP ) - withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) - withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) - withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) - withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) + withings.revoke_notification_configurations.assert_any_call( + webhook_url, NotificationCategory.IN_BED + ) + withings.revoke_notification_configurations.assert_any_call( + webhook_url, NotificationCategory.OUT_BED + ) async def test_webhook_subscription_polling_config( hass: HomeAssistant, withings: AsyncMock, polling_config_entry: MockConfigEntry, - hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test webhook subscriptions not run when polling.""" - await setup_integration(hass, polling_config_entry) - await hass_client_no_auth() + await setup_integration(hass, polling_config_entry, False) await hass.async_block_till_done() freezer.tick(timedelta(seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert withings.notify_revoke.call_count == 0 - assert withings.notify_subscribe.call_count == 0 - assert withings.notify_list.call_count == 0 + assert withings.revoke_notification_configurations.call_count == 0 + assert withings.subscribe_notification.call_count == 0 + assert withings.list_notification_configurations.call_count == 0 -@pytest.mark.parametrize( - "method", - [ - "PUT", - "HEAD", - ], -) -async def test_requests( +async def test_head_request( hass: HomeAssistant, withings: AsyncMock, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, - method: str, ) -> None: - """Test we handle request methods Withings sends.""" + """Test we handle head requests Withings sends.""" await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) response = await client.request( - method=method, + method=METH_HEAD, path=urlparse(webhook_url).path, ) assert response.status == 200 @@ -200,22 +201,24 @@ async def test_webhooks_request_data( client = await hass_client_no_auth() - assert withings.async_measure_get_meas.call_count == 1 + assert withings.get_measurement_since.call_count == 0 + assert withings.get_measurement_in_period.call_count == 1 await call_webhook( hass, WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + {"userid": USER_ID, "appli": NotificationCategory.WEIGHT}, client, ) - assert withings.async_measure_get_meas.call_count == 2 + assert withings.get_measurement_since.call_count == 1 + assert withings.get_measurement_in_period.call_count == 1 @pytest.mark.parametrize( "error", [ - UnauthorizedException(401), - AuthFailedException(500), + WithingsUnauthorizedError(401), + WithingsAuthenticationFailedError(500), ], ) async def test_triggering_reauth( @@ -223,13 +226,14 @@ async def test_triggering_reauth( withings: AsyncMock, polling_config_entry: MockConfigEntry, error: Exception, + freezer: FrozenDateTimeFactory, ) -> None: """Test triggering reauth.""" await setup_integration(hass, polling_config_entry, False) - withings.async_measure_get_meas.side_effect = error - future = dt_util.utcnow() + timedelta(minutes=10) - async_fire_time_changed(hass, future) + withings.get_measurement_since.side_effect = error + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() @@ -383,7 +387,7 @@ async def test_setup_with_cloud( "homeassistant.components.cloud.async_create_cloudhook", return_value="https://hooks.nabu.casa/ABCD", ) as fake_create_cloudhook, patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + "homeassistant.components.withings.async_get_config_entry_implementation", ), patch( "homeassistant.components.cloud.async_delete_cloudhook" ) as fake_delete_cloudhook, patch( @@ -413,12 +417,14 @@ async def test_setup_with_cloud( assert not hass.config_entries.async_entries(DOMAIN) -async def test_setup_without_https( +@pytest.mark.parametrize("url", ["http://example.com", "https://example.com:444"]) +async def test_setup_no_webhook( hass: HomeAssistant, webhook_config_entry: MockConfigEntry, withings: AsyncMock, caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, + url: str, ) -> None: """Test if set up with cloud link and without https.""" hass.config.components.add("cloud") @@ -430,7 +436,7 @@ async def test_setup_without_https( ), patch( "homeassistant.components.withings.webhook_generate_url" ) as mock_async_generate_url: - mock_async_generate_url.return_value = "http://example.com" + mock_async_generate_url.return_value = url await setup_integration(hass, webhook_config_entry) await prepare_webhook_setup(hass, freezer) @@ -461,7 +467,7 @@ async def test_cloud_disconnect( "homeassistant.components.cloud.async_create_cloudhook", return_value="https://hooks.nabu.casa/ABCD", ), patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + "homeassistant.components.withings.async_get_config_entry_implementation", ), patch( "homeassistant.components.cloud.async_delete_cloudhook" ), patch( @@ -474,34 +480,31 @@ async def test_cloud_disconnect( await hass.async_block_till_done() - withings.async_notify_list.return_value = NotifyListResponse( - **load_json_object_fixture("withings/empty_notify_list.json") - ) + withings.list_notification_configurations.return_value = [] - assert withings.async_notify_subscribe.call_count == 6 + assert withings.subscribe_notification.call_count == 6 async_mock_cloud_connection_status(hass, False) await hass.async_block_till_done() - assert withings.async_notify_revoke.call_count == 3 + assert withings.revoke_notification_configurations.call_count == 3 async_mock_cloud_connection_status(hass, True) await hass.async_block_till_done() - assert withings.async_notify_subscribe.call_count == 12 + assert withings.subscribe_notification.call_count == 12 @pytest.mark.parametrize( ("body", "expected_code"), [ - [{"userid": 0, "appli": NotifyAppli.WEIGHT.value}, 0], # Success + [{"userid": 0, "appli": NotificationCategory.WEIGHT.value}, 0], # Success [{"userid": None, "appli": 1}, 0], # Success, we ignore the user_id. [{}, 12], # No request body. [{"userid": "GG"}, 20], # appli not provided. [{"userid": 0}, 20], # appli not provided. - [{"userid": 0, "appli": 99}, 21], # Invalid appli. [ - {"userid": 11, "appli": NotifyAppli.WEIGHT.value}, + {"userid": 11, "appli": NotificationCategory.WEIGHT.value}, 0, ], # Success, we ignore the user_id ], diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 44ae10b6a94..5d42ace495b 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -1,137 +1,29 @@ """Tests for the Withings component.""" from datetime import timedelta -from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch +from aiowithings import Goals from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from withings_api.common import NotifyAppli -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.withings.const import DOMAIN, Measurement -from homeassistant.components.withings.entity import WithingsEntityDescription -from homeassistant.components.withings.sensor import SENSORS -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry -from . import call_webhook, prepare_webhook_setup, setup_integration -from .conftest import USER_ID, WEBHOOK_ID - -from tests.common import MockConfigEntry, async_fire_time_changed -from tests.typing import ClientSessionGenerator - -WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { - attr.measurement: attr for attr in SENSORS -} - - -EXPECTED_DATA = ( - (Measurement.WEIGHT_KG, 70.0), - (Measurement.FAT_MASS_KG, 5.0), - (Measurement.FAT_FREE_MASS_KG, 60.0), - (Measurement.MUSCLE_MASS_KG, 50.0), - (Measurement.BONE_MASS_KG, 10.0), - (Measurement.HEIGHT_M, 2.0), - (Measurement.FAT_RATIO_PCT, 0.07), - (Measurement.DIASTOLIC_MMHG, 70.0), - (Measurement.SYSTOLIC_MMGH, 100.0), - (Measurement.HEART_PULSE_BPM, 60.0), - (Measurement.SPO2_PCT, 0.95), - (Measurement.HYDRATION, 0.95), - (Measurement.PWV, 100.0), - (Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0), - (Measurement.SLEEP_DEEP_DURATION_SECONDS, 322), - (Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0), - (Measurement.SLEEP_HEART_RATE_MAX, 165.0), - (Measurement.SLEEP_HEART_RATE_MIN, 166.0), - (Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334), - (Measurement.SLEEP_REM_DURATION_SECONDS, 336), - (Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0), - (Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0), - (Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0), - (Measurement.SLEEP_SCORE, 222), - (Measurement.SLEEP_SNORING, 173.0), - (Measurement.SLEEP_SNORING_EPISODE_COUNT, 348), - (Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0), - (Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0), - (Measurement.SLEEP_WAKEUP_COUNT, 350), - (Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0), +from . import ( + load_activity_fixture, + load_goals_fixture, + load_measurements_fixture, + load_sleep_fixture, + load_workout_fixture, + setup_integration, ) - -async def async_get_entity_id( - hass: HomeAssistant, - description: WithingsEntityDescription, - user_id: int, - platform: str, -) -> str | None: - """Get an entity id for a user's attribute.""" - entity_registry = er.async_get(hass) - unique_id = f"withings_{user_id}_{description.measurement.value}" - - return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - -def async_assert_state_equals( - entity_id: str, - state_obj: State, - expected: Any, - description: WithingsEntityDescription, -) -> None: - """Assert at given state matches what is expected.""" - assert state_obj, f"Expected entity {entity_id} to exist but it did not" - - assert state_obj.state == str(expected), ( - f"Expected {expected} but was {state_obj.state} " - f"for measure {description.measurement}, {entity_id}" - ) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensor_default_enabled_entities( - hass: HomeAssistant, - withings: AsyncMock, - webhook_config_entry: MockConfigEntry, - hass_client_no_auth: ClientSessionGenerator, - freezer: FrozenDateTimeFactory, -) -> None: - """Test entities enabled by default.""" - await setup_integration(hass, webhook_config_entry) - await prepare_webhook_setup(hass, freezer) - entity_registry: EntityRegistry = er.async_get(hass) - - client = await hass_client_no_auth() - # Assert entities should exist. - for attribute in SENSORS: - entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN) - assert entity_id - assert entity_registry.async_is_registered(entity_id) - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.SLEEP}, - client, - ) - assert resp.message_code == 0 - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, - client, - ) - assert resp.message_code == 0 - - for measurement, expected in EXPECTED_DATA: - attribute = WITHINGS_MEASUREMENTS_MAP[measurement] - entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN) - state_obj = hass.states.get(entity_id) - - async_assert_state_equals(entity_id, state_obj, expected, attribute) +from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.freeze_time("2023-10-21") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, @@ -140,11 +32,18 @@ async def test_all_entities( polling_config_entry: MockConfigEntry, ) -> None: """Test all entities.""" - await setup_integration(hass, polling_config_entry) + with patch("homeassistant.components.withings.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, polling_config_entry) + entity_registry = er.async_get(hass) + entity_entries = er.async_entries_for_config_entry( + entity_registry, polling_config_entry.entry_id + ) - for sensor in SENSORS: - entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) - assert hass.states.get(entity_id) == snapshot + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=entity_entry.entity_id + ) async def test_update_failed( @@ -157,7 +56,7 @@ async def test_update_failed( """Test all entities.""" await setup_integration(hass, polling_config_entry, False) - withings.async_measure_get_meas.side_effect = Exception + withings.get_measurement_since.side_effect = Exception freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -165,3 +64,298 @@ async def test_update_failed( state = hass.states.get("sensor.henk_weight") assert state is not None assert state.state == STATE_UNAVAILABLE + + +async def test_update_updates_incrementally( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fetching new data updates since the last valid update.""" + await setup_integration(hass, polling_config_entry, False) + + async def _skip_10_minutes() -> None: + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert withings.get_measurement_since.call_args_list == [] + await _skip_10_minutes() + assert ( + str(withings.get_measurement_since.call_args_list[0].args[0]) + == "2019-08-01 12:00:00+00:00" + ) + + withings.get_measurement_since.return_value = load_measurements_fixture( + "withings/measurements_1.json" + ) + + await _skip_10_minutes() + assert ( + str(withings.get_measurement_since.call_args_list[1].args[0]) + == "2019-08-01 12:00:00+00:00" + ) + + await _skip_10_minutes() + assert ( + str(withings.get_measurement_since.call_args_list[2].args[0]) + == "2021-04-16 20:30:55+00:00" + ) + + state = hass.states.get("sensor.henk_weight") + assert state is not None + assert state.state == "71" + assert len(withings.get_measurement_in_period.call_args_list) == 1 + + +async def test_update_new_measurement_creates_new_sensor( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fetching a new measurement will add a new sensor.""" + withings.get_measurement_in_period.return_value = load_measurements_fixture( + "withings/measurements_1.json" + ) + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_fat_mass") is None + + withings.get_measurement_in_period.return_value = load_measurements_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_fat_mass") + + +async def test_update_new_goals_creates_new_sensor( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fetching new goals will add a new sensor.""" + + withings.get_goals.return_value = load_goals_fixture("withings/goals_1.json") + + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_step_goal") is None + assert hass.states.get("sensor.henk_weight_goal") + + withings.get_goals.return_value = load_goals_fixture() + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_step_goal") + + +async def test_activity_sensors_unknown_next_day( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will return unknown the next day.""" + freezer.move_to("2023-10-21") + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today") + + withings.get_activities_since.return_value = [] + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today").state == STATE_UNKNOWN + + +async def test_activity_sensors_same_result_same_day( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will return the same result if old data is updated.""" + freezer.move_to("2023-10-21") + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today").state == "1155" + + withings.get_activities_since.return_value = [] + + freezer.tick(timedelta(hours=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today").state == "1155" + + +async def test_activity_sensors_created_when_existed( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will be added if they existed before.""" + freezer.move_to("2023-10-21") + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today") + assert hass.states.get("sensor.henk_steps_today").state != STATE_UNKNOWN + + withings.get_activities_in_period.return_value = [] + + await hass.config_entries.async_reload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today").state == STATE_UNKNOWN + + +async def test_activity_sensors_created_when_receive_activity_data( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will be added if we receive activity data.""" + freezer.move_to("2023-10-21") + withings.get_activities_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today") is None + + withings.get_activities_in_period.return_value = load_activity_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sleep_sensors_created_when_existed( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sleep sensors will be added if they existed before.""" + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_deep_sleep") + assert hass.states.get("sensor.henk_deep_sleep").state != STATE_UNKNOWN + + withings.get_sleep_summary_since.return_value = [] + + await hass.config_entries.async_reload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_deep_sleep").state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sleep_sensors_created_when_receive_sleep_data( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sleep sensors will be added if we receive sleep data.""" + withings.get_sleep_summary_since.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_deep_sleep") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_deep_sleep") is None + + withings.get_sleep_summary_since.return_value = load_sleep_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_deep_sleep") + + +async def test_workout_sensors_created_when_existed( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test workout sensors will be added if they existed before.""" + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_last_workout_type") + assert hass.states.get("sensor.henk_last_workout_type").state != STATE_UNKNOWN + + withings.get_workouts_in_period.return_value = [] + + await hass.config_entries.async_reload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type").state == STATE_UNKNOWN + + +async def test_workout_sensors_created_when_receive_workout_data( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test workout sensors will be added if we receive workout data.""" + withings.get_workouts_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_last_workout_type") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type") is None + + withings.get_workouts_in_period.return_value = load_workout_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type") + + +async def test_warning_if_no_entities_created( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we log a warning if no entities are created at startup.""" + withings.get_workouts_in_period.return_value = [] + withings.get_goals.return_value = Goals(None, None, None) + withings.get_measurement_in_period.return_value = [] + withings.get_sleep_summary_since.return_value = [] + withings.get_activities_since.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert "No data found for Withings entry" in caplog.text diff --git a/tests/components/wled/snapshots/test_binary_sensor.ambr b/tests/components/wled/snapshots/test_binary_sensor.ambr index 7520ea7a6a6..6fc9b2497b5 100644 --- a/tests/components/wled/snapshots/test_binary_sensor.ambr +++ b/tests/components/wled/snapshots/test_binary_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': 'Firmware', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_update', @@ -68,6 +69,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index da487b49489..1c65a094662 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': 'Restart', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_restart', @@ -68,6 +69,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 96b465616c4..47dafe039b2 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -44,6 +44,7 @@ 'original_icon': None, 'original_name': 'Segment 1 Intensity', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_intensity_1', @@ -76,6 +77,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -127,6 +129,7 @@ 'original_icon': 'mdi:speedometer', 'original_name': 'Segment 1 Speed', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_speed_1', @@ -159,6 +162,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 9cfc6c6e3fe..92604f86d2d 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -47,6 +47,7 @@ 'original_icon': 'mdi:theater', 'original_name': 'Live override', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'live_override', 'unique_id': 'aabbccddeeff_live_override', @@ -79,6 +80,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -226,6 +228,7 @@ 'original_icon': 'mdi:palette-outline', 'original_name': 'Segment 1 color palette', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_palette_1', @@ -258,6 +261,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -309,6 +313,7 @@ 'original_icon': 'mdi:play-speed', 'original_name': 'Playlist', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'playlist', 'unique_id': 'aabbccddee11_playlist', @@ -341,6 +346,7 @@ 'model': 'DIY light', 'name': 'WLED RGBW Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.6b4', 'via_device_id': None, @@ -392,6 +398,7 @@ 'original_icon': 'mdi:playlist-play', 'original_name': 'Preset', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'preset', 'unique_id': 'aabbccddee11_preset', @@ -424,6 +431,7 @@ 'model': 'DIY light', 'name': 'WLED RGBW Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.6b4', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index 1434d2b2b2d..feecfd1e1ff 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -39,6 +39,7 @@ 'original_icon': 'mdi:weather-night', 'original_name': 'Nightlight', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'nightlight', 'unique_id': 'aabbccddeeff_nightlight', @@ -71,6 +72,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -113,6 +115,7 @@ 'original_icon': 'mdi:swap-horizontal-bold', 'original_name': 'Reverse', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_reverse_0', @@ -145,6 +148,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -188,6 +192,7 @@ 'original_icon': 'mdi:download-network-outline', 'original_name': 'Sync receive', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sync_receive', 'unique_id': 'aabbccddeeff_sync_receive', @@ -220,6 +225,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -263,6 +269,7 @@ 'original_icon': 'mdi:upload-network-outline', 'original_name': 'Sync send', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sync_send', 'unique_id': 'aabbccddeeff_sync_send', @@ -295,6 +302,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index de01510adb3..949916aaccc 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from wled import WLEDConnectionError from homeassistant.components import zeroconf -from homeassistant.components.wled.const import CONF_KEEP_MASTER_LIGHT, DOMAIN +from homeassistant.components.wled.const import CONF_KEEP_MAIN_LIGHT, DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant @@ -271,10 +271,10 @@ async def test_options_flow( result2 = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_KEEP_MASTER_LIGHT: True}, + user_input={CONF_KEEP_MAIN_LIGHT: True}, ) assert result2.get("type") == FlowResultType.CREATE_ENTRY assert result2.get("data") == { - CONF_KEEP_MASTER_LIGHT: True, + CONF_KEEP_MAIN_LIGHT: True, } diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index ab8330293ba..2594c228eda 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ATTR_TRANSITION, DOMAIN as LIGHT_DOMAIN, ) -from homeassistant.components.wled.const import CONF_KEEP_MASTER_LIGHT, SCAN_INTERVAL +from homeassistant.components.wled.const import CONF_KEEP_MAIN_LIGHT, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, @@ -355,7 +355,7 @@ async def test_single_segment_with_keep_main_light( assert not hass.states.get("light.wled_rgb_light_main") hass.config_entries.async_update_entry( - init_integration, options={CONF_KEEP_MASTER_LIGHT: True} + init_integration, options={CONF_KEEP_MAIN_LIGHT: True} ) await hass.async_block_till_done() diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 5c387e9a179..eeeb765e4a8 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -4,9 +4,7 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -import voluptuous as vol -from homeassistant.components.workday import binary_sensor from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC @@ -38,21 +36,6 @@ from . import ( ) -async def test_valid_country_yaml() -> None: - """Test valid country from yaml.""" - # Invalid UTF-8, must not contain U+D800 to U+DFFF - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\ud800") - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\udfff") - # Country MUST NOT be empty - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("") - # Country must be supported by holidays - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("HomeAssistantLand") - - @pytest.mark.parametrize( ("config", "expected_state"), [ @@ -89,35 +72,6 @@ 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 is not None - 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.""" await async_setup_component( diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 65e6c70fa00..89a001e0b55 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -9,11 +9,9 @@ from homeassistant.components.workday.const import ( CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, - CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, DEFAULT_EXCLUDES, - DEFAULT_NAME, DEFAULT_OFFSET, DEFAULT_WORKDAYS, DOMAIN, @@ -24,8 +22,6 @@ from homeassistant.data_entry_flow import FlowResultType from . import init_integration -from tests.common import MockConfigEntry - pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -53,7 +49,6 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -68,7 +63,6 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, } @@ -84,7 +78,6 @@ async def test_form_no_country(hass: HomeAssistant) -> None: result["flow_id"], { CONF_NAME: "Workday Sensor", - CONF_COUNTRY: "none", }, ) await hass.async_block_till_done() @@ -104,13 +97,11 @@ async def test_form_no_country(hass: HomeAssistant) -> None: assert result3["title"] == "Workday Sensor" assert result3["options"] == { "name": "Workday Sensor", - "country": None, "excludes": ["sat", "sun", "holiday"], "days_offset": 0, "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, } @@ -152,146 +143,9 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: "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.""" @@ -305,7 +159,6 @@ async def test_options_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, }, ) @@ -360,7 +213,6 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-xx-12"], CONF_REMOVE_HOLIDAYS: [], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -374,7 +226,6 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["Does not exist"], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -389,7 +240,6 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["Weihnachtstag"], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -404,7 +254,6 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": ["2022-12-12"], "remove_holidays": ["Weihnachtstag"], - "province": None, } @@ -421,7 +270,6 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, }, ) @@ -522,7 +370,6 @@ async def test_options_form_abort_duplicate(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": "none", }, ) @@ -554,7 +401,6 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-30,2022-12-32"], CONF_REMOVE_HOLIDAYS: [], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -568,7 +414,6 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-32"], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -583,7 +428,6 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-01,2022-12-10"], CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-31"], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -598,7 +442,6 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": ["2022-12-12", "2022-12-01,2022-12-10"], "remove_holidays": ["2022-12-25", "2022-12-30,2022-12-31"], - "province": None, } @@ -615,7 +458,6 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, }, ) diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index 38b2142dfb7..d1920b7dc26 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -126,7 +126,7 @@ async def test_bad_country_none( data = await resp.json() url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"province": "none"}) + resp = await client.post(url, json={}) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -303,7 +303,7 @@ async def test_bad_province_none( assert data["step_id"] == "province" url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"province": "none"}) + resp = await client.post(url, json={}) assert resp.status == HTTPStatus.OK data = await resp.json() diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index b439ce04c25..fb9ecc9bea4 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -641,5 +641,5 @@ async def test_async_setup_retries_with_wrong_device( assert config_entry.state is ConfigEntryState.SETUP_RETRY assert ( "Unexpected device found at 192.168.1.239; expected 0x0000000000999999, " - "found 0x000000000015243f; Retrying in background" + "found 0x000000000015243f; Retrying in" ) in caplog.text diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 47dbd54baa9..441ec202b28 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -889,6 +889,7 @@ async def test_device_types( "mono", { "effect_list": YEELIGHT_MONO_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "brightness": bright, "color_mode": "brightness", @@ -903,6 +904,7 @@ async def test_device_types( { "effect_list": YEELIGHT_MONO_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "effect": None, "brightness": bright, "color_mode": "brightness", "supported_color_modes": ["brightness"], @@ -917,6 +919,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -944,6 +947,7 @@ async def test_device_types( }, nightlight_mode_properties={ "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "hs_color": (28.401, 100.0), "rgb_color": (255, 120, 0), @@ -976,6 +980,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -991,6 +996,8 @@ async def test_device_types( "hs_color": hs_color, "rgb_color": color_hs_to_RGB(*hs_color), "xy_color": color_hs_to_xy(*hs_color), + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1009,6 +1016,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1024,6 +1032,8 @@ async def test_device_types( "hs_color": color_RGB_to_hs(*rgb_color), "rgb_color": rgb_color, "xy_color": color_RGB_to_xy(*rgb_color), + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1043,6 +1053,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1055,6 +1066,11 @@ async def test_device_types( model_specs["color_temp"]["min"] ), "brightness": bright, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1074,6 +1090,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1086,6 +1103,11 @@ async def test_device_types( model_specs["color_temp"]["min"] ), "brightness": bright, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1104,6 +1126,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1115,6 +1138,12 @@ async def test_device_types( "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), + "brightness": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "unknown", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1133,6 +1162,7 @@ async def test_device_types( "ceiling1", { "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": color_temperature_mired_to_kelvin( color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) @@ -1163,6 +1193,7 @@ async def test_device_types( }, nightlight_mode_properties={ "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": color_temperature_mired_to_kelvin( color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) @@ -1201,6 +1232,7 @@ async def test_device_types( { "friendly_name": NAME, "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "effect": None, "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, @@ -1234,6 +1266,7 @@ async def test_device_types( nightlight_mode_properties={ "friendly_name": NAME, "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "effect": None, "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, @@ -1270,6 +1303,7 @@ async def test_device_types( "ceiling4", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1297,6 +1331,7 @@ async def test_device_types( "ceiling4", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1308,6 +1343,8 @@ async def test_device_types( "hs_color": bg_hs_color, "rgb_color": color_hs_to_RGB(*bg_hs_color), "xy_color": color_hs_to_xy(*bg_hs_color), + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1322,6 +1359,7 @@ async def test_device_types( "ceiling4", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1333,6 +1371,8 @@ async def test_device_types( "hs_color": color_RGB_to_hs(*bg_rgb_color), "rgb_color": bg_rgb_color, "xy_color": color_RGB_to_xy(*bg_rgb_color), + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, diff --git a/tests/components/zerproc/test_light.py b/tests/components/zerproc/test_light.py index a733ab8e5bb..662a75fb7c8 100644 --- a/tests/components/zerproc/test_light.py +++ b/tests/components/zerproc/test_light.py @@ -102,6 +102,11 @@ async def test_init(hass: HomeAssistant, mock_entry) -> None: ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_SUPPORTED_FEATURES: 0, ATTR_ICON: "mdi:string-lights", + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_XY_COLOR: None, } state = hass.states.get("light.ledblue_33445566") @@ -283,6 +288,11 @@ async def test_light_update(hass: HomeAssistant, mock_light) -> None: ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_SUPPORTED_FEATURES: 0, ATTR_ICON: "mdi:string-lights", + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_XY_COLOR: None, } # Make sure no discovery calls are made while we emulate time passing @@ -320,6 +330,11 @@ async def test_light_update(hass: HomeAssistant, mock_light) -> None: ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_SUPPORTED_FEATURES: 0, ATTR_ICON: "mdi:string-lights", + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_XY_COLOR: None, } with patch.object( diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index e7dc7316f73..9d9d74e72df 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -2,7 +2,9 @@ from collections.abc import Callable, Generator import itertools import time -from unittest.mock import AsyncMock, MagicMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch +import warnings import pytest import zigpy @@ -14,6 +16,7 @@ import zigpy.device import zigpy.group import zigpy.profiles import zigpy.quirks +import zigpy.state import zigpy.types import zigpy.util from zigpy.zcl.clusters.general import Basic, Groups @@ -92,7 +95,9 @@ class _FakeApp(ControllerApplication): async def start_network(self): pass - async def write_network_info(self): + async def write_network_info( + self, *, network_info: zigpy.state.NetworkInfo, node_info: zigpy.state.NodeInfo + ) -> None: pass async def request( @@ -111,9 +116,33 @@ class _FakeApp(ControllerApplication): ): pass + async def move_network_to_channel( + self, new_channel: int, *, num_broadcasts: int = 5 + ) -> None: + pass + + +def _wrap_mock_instance(obj: Any) -> MagicMock: + """Auto-mock every attribute and method in an object.""" + mock = create_autospec(obj, spec_set=True, instance=True) + + for attr_name in dir(obj): + if attr_name.startswith("__") and attr_name not in {"__getitem__"}: + continue + + real_attr = getattr(obj, attr_name) + mock_attr = getattr(mock, attr_name) + + if callable(real_attr): + mock_attr.side_effect = real_attr + else: + setattr(mock, attr_name, real_attr) + + return mock + @pytest.fixture -def zigpy_app_controller(): +async def zigpy_app_controller(): """Zigpy ApplicationController fixture.""" app = _FakeApp( { @@ -145,14 +174,14 @@ def zigpy_app_controller(): ep.add_input_cluster(Basic.cluster_id) ep.add_input_cluster(Groups.cluster_id) - with patch( - "zigpy.device.Device.request", return_value=[Status.SUCCESS] - ), patch.object(app, "permit", autospec=True), patch.object( - app, "startup", wraps=app.startup - ), patch.object( - app, "permit_with_key", autospec=True - ): - yield app + with patch("zigpy.device.Device.request", return_value=[Status.SUCCESS]): + # The mock wrapping accesses deprecated attributes, so we suppress the warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_app = _wrap_mock_instance(app) + mock_app.backups = _wrap_mock_instance(app.backups) + + yield mock_app @pytest.fixture(name="config_entry") @@ -189,12 +218,17 @@ def mock_zigpy_connect( with patch( "bellows.zigbee.application.ControllerApplication.new", return_value=zigpy_app_controller, - ) as mock_app: - yield mock_app + ), patch( + "bellows.zigbee.application.ControllerApplication", + return_value=zigpy_app_controller, + ): + yield zigpy_app_controller @pytest.fixture -def setup_zha(hass, config_entry: MockConfigEntry, mock_zigpy_connect): +def setup_zha( + hass, config_entry: MockConfigEntry, mock_zigpy_connect: ControllerApplication +): """Set up ZHA component.""" zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} @@ -202,12 +236,11 @@ def setup_zha(hass, config_entry: MockConfigEntry, mock_zigpy_connect): config_entry.add_to_hass(hass) config = config or {} - with mock_zigpy_connect: - status = await async_setup_component( - hass, zha_const.DOMAIN, {zha_const.DOMAIN: {**zha_config, **config}} - ) - assert status is True - await hass.async_block_till_done() + status = await async_setup_component( + hass, zha_const.DOMAIN, {zha_const.DOMAIN: {**zha_config, **config}} + ) + assert status is True + await hass.async_block_till_done() return _setup @@ -394,3 +427,74 @@ def speed_up_radio_mgr(): """Speed up the radio manager connection time by removing delays.""" with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.00001): yield + + +@pytest.fixture +def network_backup() -> zigpy.backups.NetworkBackup: + """Real ZHA network backup taken from an active instance.""" + return zigpy.backups.NetworkBackup.from_dict( + { + "backup_time": "2022-11-16T03:16:49.427675+00:00", + "network_info": { + "extended_pan_id": "2f:73:58:bd:fe:78:91:11", + "pan_id": "2DB4", + "nwk_update_id": 0, + "nwk_manager_id": "0000", + "channel": 15, + "channel_mask": [ + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + ], + "security_level": 5, + "network_key": { + "key": "4a:c7:9d:50:51:09:16:37:2e:34:66:c6:ed:9b:23:85", + "tx_counter": 14131, + "rx_counter": 0, + "seq": 0, + "partner_ieee": "ff:ff:ff:ff:ff:ff:ff:ff", + }, + "tc_link_key": { + "key": "5a:69:67:42:65:65:41:6c:6c:69:61:6e:63:65:30:39", + "tx_counter": 0, + "rx_counter": 0, + "seq": 0, + "partner_ieee": "84:ba:20:ff:fe:59:f5:ff", + }, + "key_table": [], + "children": [], + "nwk_addresses": {"cc:cc:cc:ff:fe:e6:8e:ca": "1431"}, + "stack_specific": { + "ezsp": {"hashed_tclk": "e9bd3ac165233d95923613c608beb147"} + }, + "metadata": { + "ezsp": { + "manufacturer": "", + "board": "", + "version": "7.1.3.0 build 0", + "stack_version": 9, + "can_write_custom_eui64": False, + } + }, + "source": "bellows@0.34.2", + }, + "node_info": { + "nwk": "0000", + "ieee": "84:ba:20:ff:fe:59:f5:ff", + "logical_type": "coordinator", + }, + } + ) diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 89742fb1e49..c3dac0ddd8c 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from unittest.mock import call, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest import zigpy.backups @@ -48,21 +48,18 @@ async def test_async_get_network_settings_inactive( backup.network_info.channel = 20 zigpy_app_controller.backups.backups.append(backup) - 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: + controller = AsyncMock() + controller.SCHEMA = zigpy_app_controller.SCHEMA + controller.new = AsyncMock(return_value=zigpy_app_controller) + + with patch.dict( + "homeassistant.components.zha.core.const.RadioType._member_map_", + ezsp=MagicMock(controller=controller, description="EZSP"), + ): settings = await api.async_get_network_settings(hass) - assert len(mock_load_db.mock_calls) == 1 - assert len(mock_start_network.mock_calls) == 0 assert settings.network_info.channel == 20 + assert len(zigpy_app_controller.start_network.mock_calls) == 0 async def test_async_get_network_settings_missing( @@ -78,11 +75,7 @@ async def test_async_get_network_settings_missing( zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() - with patch( - "bellows.zigbee.application.ControllerApplication.__new__", - return_value=zigpy_app_controller, - ): - settings = await api.async_get_network_settings(hass) + settings = await api.async_get_network_settings(hass) assert settings is None @@ -115,12 +108,8 @@ async def test_change_channel( """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)] + await api.async_change_channel(hass, 20) + assert zigpy_app_controller.move_network_to_channel.mock_calls == [call(20)] async def test_change_channel_auto( @@ -129,16 +118,10 @@ async def test_change_channel_auto( """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 - ): + zigpy_app_controller.energy_scan.side_effect = None + zigpy_app_controller.energy_scan.return_value = {c: c for c in range(11, 26 + 1)} + + with 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)] + assert zigpy_app_controller.move_network_to_channel.mock_calls == [call(25)] diff --git a/tests/components/zha/test_backup.py b/tests/components/zha/test_backup.py index 9ce692b41ae..bee00c5a587 100644 --- a/tests/components/zha/test_backup.py +++ b/tests/components/zha/test_backup.py @@ -1,17 +1,24 @@ """Unit tests for ZHA backup platform.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock + +from zigpy.application import ControllerApplication from homeassistant.components.zha.backup import async_post_backup, async_pre_backup from homeassistant.core import HomeAssistant -async def test_pre_backup(hass: HomeAssistant, setup_zha) -> None: +async def test_pre_backup( + hass: HomeAssistant, zigpy_app_controller: ControllerApplication, setup_zha +) -> None: """Test backup creation when `async_pre_backup` is called.""" - with patch("zigpy.backups.BackupManager.create_backup", AsyncMock()) as backup_mock: - await setup_zha() - await async_pre_backup(hass) + await setup_zha() - backup_mock.assert_called_once_with(load_devices=True) + zigpy_app_controller.backups.create_backup = AsyncMock() + await async_pre_backup(hass) + + zigpy_app_controller.backups.create_backup.assert_called_once_with( + load_devices=True + ) async def test_post_backup(hass: HomeAssistant, setup_zha) -> None: diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 1b4f5b56924..b41499dada7 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -98,10 +98,22 @@ async def async_test_iaszone_on_off(hass, cluster, entity_id): @pytest.mark.parametrize( - ("device", "on_off_test", "cluster_name", "reporting"), + ("device", "on_off_test", "cluster_name", "reporting", "name"), [ - (DEVICE_IAS, async_test_iaszone_on_off, "ias_zone", (0,)), - (DEVICE_OCCUPANCY, async_test_binary_sensor_on_off, "occupancy", (1,)), + ( + DEVICE_IAS, + async_test_iaszone_on_off, + "ias_zone", + (0,), + "FakeManufacturer FakeModel IAS zone", + ), + ( + DEVICE_OCCUPANCY, + async_test_binary_sensor_on_off, + "occupancy", + (1,), + "FakeManufacturer FakeModel Occupancy", + ), ], ) async def test_binary_sensor( @@ -112,6 +124,7 @@ async def test_binary_sensor( on_off_test, cluster_name, reporting, + name, ) -> None: """Test ZHA binary_sensor platform.""" zigpy_device = zigpy_device_mock(device) @@ -119,6 +132,7 @@ async def test_binary_sensor( entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) assert entity_id is not None + assert hass.states.get(entity_id).name == name assert hass.states.get(entity_id).state == STATE_OFF await async_enable_traffic(hass, [zha_device], enabled=False) # test that the sensors exist and are in the unavailable state @@ -186,11 +200,12 @@ async def test_binary_sensor_migration_not_migrated( ) -> None: """Test temporary ZHA IasZone binary_sensor migration to zigpy cache.""" - entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + entity_id = "binary_sensor.fakemanufacturer_fakemodel_ias_zone" core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock(DEVICE_IAS) + zha_device = await zha_device_restored(zigpy_device) entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) @@ -209,7 +224,7 @@ async def test_binary_sensor_migration_already_migrated( ) -> None: """Test temporary ZHA IasZone binary_sensor migration doesn't migrate multiple times.""" - entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + entity_id = "binary_sensor.fakemanufacturer_fakemodel_ias_zone" core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) await async_mock_load_restore_state_from_storage(hass) diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 08f84613ff3..0adb7583d31 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -11,11 +11,18 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, + SERVICE_TOGGLE_COVER_TILT, ) from homeassistant.components.zha.core.const import ZHA_EVENT from homeassistant.const import ( @@ -27,6 +34,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_component import async_update_entity from .common import ( async_enable_traffic, @@ -64,7 +72,7 @@ def zigpy_cover_device(zigpy_device_mock): endpoints = { 1: { SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_DEVICE, SIG_EP_INPUT: [closures.WindowCovering.cluster_id], SIG_EP_OUTPUT: [], } @@ -130,10 +138,14 @@ async def test_cover( # load up cover domain cluster = zigpy_cover_device.endpoints.get(1).window_covering - cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 100} + cluster.PLUGGED_ATTR_READS = { + "current_position_lift_percentage": 65, + "current_position_tilt_percentage": 42, + } zha_device = await zha_device_joined_restored(zigpy_cover_device) assert cluster.read_attributes.call_count == 1 assert "current_position_lift_percentage" in cluster.read_attributes.call_args[0][0] + assert "current_position_tilt_percentage" in cluster.read_attributes.call_args[0][0] entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None @@ -146,6 +158,16 @@ async def test_cover( await async_enable_traffic(hass, [zha_device]) await hass.async_block_till_done() + # test update + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 2 + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 35 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 58 + # test that the state has changed from unavailable to off await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == STATE_CLOSED @@ -154,6 +176,14 @@ async def test_cover( await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100}) assert hass.states.get(entity_id).state == STATE_OPEN + # test that the state remains after tilting to 100% + await send_attributes_report(hass, cluster, {0: 0, 9: 100, 1: 1}) + assert hass.states.get(entity_id).state == STATE_OPEN + + # test to see the state remains after tilting to 0% + await send_attributes_report(hass, cluster, {0: 1, 9: 0, 1: 100}) + assert hass.states.get(entity_id).state == STATE_OPEN + # close from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -165,6 +195,20 @@ async def test_cover( assert cluster.request.call_args[0][2].command.name == "down_close" assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 100 + assert cluster.request.call_args[1]["expect_reply"] is True + # open from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -176,6 +220,20 @@ async def test_cover( assert cluster.request.call_args[0][2].command.name == "up_open" assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 0 + assert cluster.request.call_args[1]["expect_reply"] is True + # set position UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -191,6 +249,20 @@ async def test_cover( assert cluster.request.call_args[0][3] == 53 assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {"entity_id": entity_id, ATTR_TILT_POSITION: 47}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 53 + assert cluster.request.call_args[1]["expect_reply"] is True + # stop from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -202,11 +274,39 @@ async def test_cover( assert cluster.request.call_args[0][2].command.name == "stop" assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x02 + assert cluster.request.call_args[0][2].command.name == "stop" + assert cluster.request.call_args[1]["expect_reply"] is True + # test rejoin cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 0} await async_test_rejoin(hass, zigpy_cover_device, [cluster], (1,)) assert hass.states.get(entity_id).state == STATE_OPEN + # test toggle + with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 100 + assert cluster.request.call_args[1]["expect_reply"] is True + async def test_cover_failures( hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device @@ -215,7 +315,10 @@ async def test_cover_failures( # load up cover domain cluster = zigpy_cover_device.endpoints.get(1).window_covering - cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 100} + cluster.PLUGGED_ATTR_READS = { + "current_position_lift_percentage": None, + "current_position_tilt_percentage": 42, + } zha_device = await zha_device_joined_restored(zigpy_cover_device) entity_id = find_entity_id(Platform.COVER, zha_device, hass) @@ -225,11 +328,17 @@ async def test_cover_failures( # test that the cover was created and that it is unavailable assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + # test update returned None + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 2 + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + # allow traffic to flow through the gateway and device await async_enable_traffic(hass, [zha_device]) await hass.async_block_till_done() - # test that the state has changed from unavailable to off + # test that the state has changed from unavailable to closed await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == STATE_CLOSED @@ -258,6 +367,26 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.down_close.id ) + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to close cover tilt"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + # open from UI with patch( "zigpy.zcl.Cluster.request", @@ -279,6 +408,26 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.up_open.id ) + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to open cover tilt"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + # set position UI with patch( "zigpy.zcl.Cluster.request", @@ -301,6 +450,28 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.go_to_lift_percentage.id ) + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises( + HomeAssistantError, match=r"Failed to set cover tilt position" + ): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {"entity_id": entity_id, "tilt_position": 42}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + # stop from UI with patch( "zigpy.zcl.Cluster.request", @@ -499,11 +670,10 @@ async def test_shade( assert cluster_level.request.call_args[0][1] in (0x0003, 0x0007) -async def test_restore_state( +async def test_shade_restore_state( hass: HomeAssistant, zha_device_restored, zigpy_shade_device ) -> None: """Ensure states are restored on startup.""" - mock_restore_cache( hass, ( @@ -521,11 +691,38 @@ async def test_restore_state( entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None - # test that the cover was created and that it is unavailable + # test that the cover was created and that it is available assert hass.states.get(entity_id).state == STATE_OPEN assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 50 +async def test_cover_restore_state( + hass: HomeAssistant, zha_device_restored, zigpy_cover_device +) -> None: + """Ensure states are restored on startup.""" + mock_restore_cache( + hass, + ( + State( + "cover.fakemanufacturer_fakemodel_cover", + STATE_OPEN, + {ATTR_CURRENT_POSITION: 50, ATTR_CURRENT_TILT_POSITION: 42}, + ), + ), + ) + + hass.state = CoreState.starting + + zha_device = await zha_device_restored(zigpy_cover_device) + entity_id = find_entity_id(Platform.COVER, zha_device, hass) + assert entity_id is not None + + # test that the cover was created and that it is available + assert hass.states.get(entity_id).state == STATE_OPEN + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 50 + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_TILT_POSITION] == 42 + + async def test_keen_vent( hass: HomeAssistant, zha_device_joined_restored, zigpy_keen_vent ) -> None: diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 096d83567fe..c3563872873 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -4,6 +4,7 @@ import time from unittest.mock import patch import pytest +from zigpy.application import ControllerApplication import zigpy.profiles.zha import zigpy.zcl.clusters.general as general @@ -408,7 +409,7 @@ async def test_validate_trigger_config_missing_info( hass: HomeAssistant, config_entry: MockConfigEntry, zigpy_device_mock, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, zha_device_joined, caplog: pytest.LogCaptureFixture, ) -> None: @@ -461,7 +462,7 @@ async def test_validate_trigger_config_unloaded_bad_info( hass: HomeAssistant, config_entry: MockConfigEntry, zigpy_device_mock, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, zha_device_joined, caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 768f974d928..c55f614d80f 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -144,7 +144,7 @@ async def test_devices( _, 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, cluster_handlers) - if response and not contains_ignored_suffix(response.name): + if response and not contains_ignored_suffix(response.unique_id): created_entity_count += 1 unique_id_head = UNIQUE_ID_HD.match(unique_id).group( 0 diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 81ab1c2e0f5..737604482d8 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -76,7 +76,7 @@ def fan_platform_only(): @pytest.fixture def zigpy_device(zigpy_device_mock): - """Device tracker zigpy device.""" + """Fan zigpy device.""" endpoints = { 1: { SIG_EP_INPUT: [hvac.Fan.cluster_id], @@ -540,7 +540,7 @@ async def test_fan_update_entity( @pytest.fixture def zigpy_device_ikea(zigpy_device_mock): - """Device tracker zigpy device.""" + """Ikea fan zigpy device.""" endpoints = { 1: { SIG_EP_INPUT: [ @@ -725,3 +725,179 @@ async def test_fan_ikea_update_entity( assert cluster.read_attributes.await_count == 5 else: assert cluster.read_attributes.await_count == 8 + + +@pytest.fixture +def zigpy_device_kof(zigpy_device_mock): + """Fan by King of Fans zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + 64637, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + } + return zigpy_device_mock( + endpoints, + manufacturer="King Of Fans, Inc.", + model="HBUniversalCFRemote", + quirk=zhaquirks.kof.kof_mr101z.CeilingFan, + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + +async def test_fan_kof( + hass: HomeAssistant, + zha_device_joined_restored: ZHADevice, + zigpy_device_kof: Device, +) -> None: + """Test ZHA fan platform for King of Fans.""" + zha_device = await zha_device_joined_restored(zigpy_device_kof) + cluster = zigpy_device_kof.endpoints.get(1).fan + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_OFF + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the fan was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on at fan + await send_attributes_report(hass, cluster, {1: 2, 0: 1, 2: 3}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn off at fan + await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2}) + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(hass, entity_id) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 2}, manufacturer=None) + ] + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(hass, entity_id) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 0}, manufacturer=None) + ] + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(hass, entity_id, percentage=100) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 4}, manufacturer=None) + ] + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_SMART) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 6}, manufacturer=None) + ] + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert len(cluster.write_attributes.mock_calls) == 0 + + # test adding new fan to the network and HA + await async_test_rejoin(hass, zigpy_device_kof, [cluster], (1,)) + + +@pytest.mark.parametrize( + ("plug_read", "expected_state", "expected_percentage", "expected_preset"), + ( + (None, STATE_OFF, None, None), + ({"fan_mode": 0}, STATE_OFF, 0, None), + ({"fan_mode": 1}, STATE_ON, 25, None), + ({"fan_mode": 2}, STATE_ON, 50, None), + ({"fan_mode": 3}, STATE_ON, 75, None), + ({"fan_mode": 4}, STATE_ON, 100, None), + ({"fan_mode": 6}, STATE_ON, None, PRESET_MODE_SMART), + ), +) +async def test_fan_kof_init( + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device_kof, + plug_read, + expected_state, + expected_percentage, + expected_preset, +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = plug_read + + zha_device = await zha_device_joined_restored(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == expected_state + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == expected_percentage + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] == expected_preset + + +async def test_fan_kof_update_entity( + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device_kof, +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await zha_device_joined_restored(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 2 + else: + assert cluster.read_attributes.await_count == 4 + + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 3 + else: + assert cluster.read_attributes.await_count == 5 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 25 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 4 + else: + assert cluster.read_attributes.await_count == 6 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 214bfcad9f0..2a0a241c864 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -9,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.const import RadioType from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.helpers import get_zha_gateway @@ -350,13 +351,11 @@ async def test_gateway_initialize_bellows_thread( zha_gateway.config_entry.data["device"]["path"] = device_path zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ) as mock_new: - await zha_gateway.async_initialize() + await zha_gateway.async_initialize() - assert mock_new.mock_calls[0].kwargs["config"]["use_thread"] is thread_state + RadioType.ezsp.controller.new.mock_calls[-1].kwargs["config"][ + "use_thread" + ] is thread_state @pytest.mark.parametrize( diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 6bac012d667..ad6ab4e351e 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -3,6 +3,7 @@ import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest +from zigpy.application import ControllerApplication from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import TransientConnectionError @@ -12,8 +13,14 @@ from homeassistant.components.zha.core.const import ( CONF_USB_PATH, DOMAIN, ) -from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, Platform -from homeassistant.core import HomeAssistant +from homeassistant.components.zha.core.helpers import get_zha_data +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + MAJOR_VERSION, + MINOR_VERSION, + Platform, +) +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component @@ -136,7 +143,10 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: "homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True) ) async def test_setup_with_v3_cleaning_uri( - hass: HomeAssistant, path: str, cleaned_path: str, mock_zigpy_connect + hass: HomeAssistant, + path: str, + cleaned_path: str, + mock_zigpy_connect: ControllerApplication, ) -> None: """Test migration of config entry from v3, applying corrections to the port path.""" config_entry_v3 = MockConfigEntry( @@ -166,7 +176,7 @@ async def test_zha_retry_unique_ids( hass: HomeAssistant, config_entry: MockConfigEntry, zigpy_device_mock, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, caplog, ) -> None: """Test that ZHA retrying creates unique entity IDs.""" @@ -174,7 +184,7 @@ async def test_zha_retry_unique_ids( config_entry.add_to_hass(hass) # Ensure we have some device to try to load - app = mock_zigpy_connect.return_value + app = mock_zigpy_connect light = zigpy_device_mock(LIGHT_ON_OFF) app.devices[light.ieee] = light @@ -199,3 +209,26 @@ async def test_zha_retry_unique_ids( await hass.config_entries.async_unload(config_entry.entry_id) assert "does not generate unique IDs" not in caplog.text + + +async def test_shutdown_on_ha_stop( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, +) -> None: + """Test that the ZHA gateway is stopped when HA is shut down.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + zha_data = get_zha_data(hass) + + with patch.object( + zha_data.gateway, "shutdown", wraps=zha_data.gateway.shutdown + ) as mock_shutdown: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.state = CoreState.stopping + await hass.async_block_till_done() + + assert len(mock_shutdown.mock_calls) == 1 diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index da91340b864..1ec70b74735 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -1669,7 +1669,7 @@ async def test_zha_group_light_entity( ColorMode.XY, ] # Light which is off has no color mode - assert "color_mode" not in group_state.attributes + assert group_state.attributes["color_mode"] is None # test turning the lights on and off from the HA await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id) diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 1467e2e2951..67f2d0164d3 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -456,7 +456,7 @@ async def test_detect_radio_type_failure_wrong_firmware( with patch( "homeassistant.components.zha.radio_manager.AUTOPROBE_RADIOS", () ), patch( - "homeassistant.components.zha.radio_manager.repairs.warn_on_wrong_silabs_firmware", + "homeassistant.components.zha.radio_manager.repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware", return_value=True, ): assert ( @@ -473,7 +473,7 @@ async def test_detect_radio_type_failure_no_detect( with patch( "homeassistant.components.zha.radio_manager.AUTOPROBE_RADIOS", () ), patch( - "homeassistant.components.zha.radio_manager.repairs.warn_on_wrong_silabs_firmware", + "homeassistant.components.zha.radio_manager.repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware", return_value=False, ): assert await radio_manager.detect_radio_type() == ProbeResult.PROBING_FAILED diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 6f36ee624e9..68ff116adea 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -1,15 +1,14 @@ """Test ZHA registries.""" from __future__ import annotations -import importlib -import inspect import typing from unittest import mock import pytest -import zhaquirks +import zigpy.quirks as zigpy_quirks from homeassistant.components.zha.binary_sensor import IASZone +from homeassistant.components.zha.core.const import ATTR_QUIRK_ID import homeassistant.components.zha.core.registries as registries from homeassistant.helpers import entity_registry as er @@ -19,7 +18,7 @@ if typing.TYPE_CHECKING: MANUFACTURER = "mock manufacturer" MODEL = "mock model" QUIRK_CLASS = "mock.test.quirk.class" -QUIRK_CLASS_SHORT = "quirk.class" +QUIRK_ID = "quirk_id" @pytest.fixture @@ -29,6 +28,7 @@ def zha_device(): dev.manufacturer = MANUFACTURER dev.model = MODEL dev.quirk_class = QUIRK_CLASS + dev.quirk_id = QUIRK_ID return dev @@ -107,17 +107,17 @@ def cluster_handlers(cluster_handler): ), False, ), - (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), - (registries.MatchRule(quirk_classes="no match"), False), + (registries.MatchRule(quirk_ids=QUIRK_ID), True), + (registries.MatchRule(quirk_ids="no match"), False), ( registries.MatchRule( - quirk_classes=QUIRK_CLASS, aux_cluster_handlers="aux_cluster_handler" + quirk_ids=QUIRK_ID, aux_cluster_handlers="aux_cluster_handler" ), True, ), ( registries.MatchRule( - quirk_classes="no match", aux_cluster_handlers="aux_cluster_handler" + quirk_ids="no match", aux_cluster_handlers="aux_cluster_handler" ), False, ), @@ -128,7 +128,7 @@ def cluster_handlers(cluster_handler): cluster_handler_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, - quirk_classes=QUIRK_CLASS, + quirk_ids=QUIRK_ID, ), True, ), @@ -187,33 +187,31 @@ def cluster_handlers(cluster_handler): ( registries.MatchRule( cluster_handler_names="on_off", - quirk_classes={"random quirk", QUIRK_CLASS}, + quirk_ids={"random quirk", QUIRK_ID}, ), True, ), ( registries.MatchRule( cluster_handler_names="on_off", - quirk_classes={"random quirk", "another quirk"}, + quirk_ids={"random quirk", "another quirk"}, ), False, ), ( registries.MatchRule( - cluster_handler_names="on_off", quirk_classes=lambda x: x == QUIRK_CLASS + cluster_handler_names="on_off", quirk_ids=lambda x: x == QUIRK_ID ), True, ), ( registries.MatchRule( - cluster_handler_names="on_off", quirk_classes=lambda x: x != QUIRK_CLASS + cluster_handler_names="on_off", quirk_ids=lambda x: x != QUIRK_ID ), False, ), ( - registries.MatchRule( - cluster_handler_names="on_off", quirk_classes=QUIRK_CLASS_SHORT - ), + registries.MatchRule(cluster_handler_names="on_off", quirk_ids=QUIRK_ID), True, ), ], @@ -221,8 +219,7 @@ def cluster_handlers(cluster_handler): def test_registry_matching(rule, matched, cluster_handlers) -> None: """Test strict rule matching.""" assert ( - rule.strict_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_CLASS) - is matched + rule.strict_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched ) @@ -314,8 +311,8 @@ def test_registry_matching(rule, matched, cluster_handlers) -> None: (registries.MatchRule(manufacturers=MANUFACTURER), True), (registries.MatchRule(models=MODEL), True), (registries.MatchRule(models="no match"), False), - (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), - (registries.MatchRule(quirk_classes="no match"), False), + (registries.MatchRule(quirk_ids=QUIRK_ID), True), + (registries.MatchRule(quirk_ids="no match"), False), # match everything ( registries.MatchRule( @@ -323,7 +320,7 @@ def test_registry_matching(rule, matched, cluster_handlers) -> None: cluster_handler_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, - quirk_classes=QUIRK_CLASS, + quirk_ids=QUIRK_ID, ), True, ), @@ -332,8 +329,7 @@ def test_registry_matching(rule, matched, cluster_handlers) -> None: def test_registry_loose_matching(rule, matched, cluster_handlers) -> None: """Test loose rule matching.""" assert ( - rule.loose_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_CLASS) - is matched + rule.loose_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched ) @@ -397,12 +393,12 @@ def entity_registry(): @pytest.mark.parametrize( - ("manufacturer", "model", "quirk_class", "match_name"), + ("manufacturer", "model", "quirk_id", "match_name"), ( ("random manufacturer", "random model", "random.class", "OnOff"), ("random manufacturer", MODEL, "random.class", "OnOffModel"), (MANUFACTURER, "random model", "random.class", "OnOffManufacturer"), - ("random manufacturer", "random model", QUIRK_CLASS, "OnOffQuirk"), + ("random manufacturer", "random model", QUIRK_ID, "OnOffQuirk"), (MANUFACTURER, MODEL, "random.class", "OnOffModelManufacturer"), (MANUFACTURER, "some model", "random.class", "OnOffMultimodel"), ), @@ -412,7 +408,7 @@ def test_weighted_match( entity_registry: er.EntityRegistry, manufacturer, model, - quirk_class, + quirk_id, match_name, ) -> None: """Test weightedd match.""" @@ -453,7 +449,7 @@ def test_weighted_match( pass @entity_registry.strict_match( - s.component, cluster_handler_names="on_off", quirk_classes=QUIRK_CLASS + s.component, cluster_handler_names="on_off", quirk_ids=QUIRK_ID ) class OnOffQuirk: pass @@ -462,7 +458,7 @@ def test_weighted_match( ch_level = cluster_handler("level", 8) match, claimed = entity_registry.get_entity( - s.component, manufacturer, model, [ch_on_off, ch_level], quirk_class + s.component, manufacturer, model, [ch_on_off, ch_level], quirk_id ) assert match.__name__ == match_name @@ -490,7 +486,7 @@ def test_multi_sensor_match( "manufacturer", "model", cluster_handlers=[ch_se, ch_illuminati], - quirk_class="quirk_class", + quirk_id="quirk_id", ) assert s.binary_sensor in match @@ -520,7 +516,7 @@ def test_multi_sensor_match( "manufacturer", "model", cluster_handlers={ch_se, ch_illuminati}, - quirk_class="quirk_class", + quirk_id="quirk_id", ) assert s.binary_sensor in match @@ -554,18 +550,10 @@ def iter_all_rules() -> typing.Iterable[registries.MatchRule, list[type[ZhaEntit def test_quirk_classes() -> None: - """Make sure that quirk_classes in components matches are valid.""" - - def find_quirk_class(base_obj, quirk_mod, quirk_cls): - """Find a specific quirk class.""" - - module = importlib.import_module(quirk_mod) - clss = dict(inspect.getmembers(module, inspect.isclass)) - # Check quirk_cls in module classes - return quirk_cls in clss + """Make sure that all quirk IDs in components matches exist.""" def quirk_class_validator(value): - """Validate quirk classes during self test.""" + """Validate quirk IDs during self test.""" if callable(value): # Callables cannot be tested return @@ -576,16 +564,22 @@ def test_quirk_classes() -> None: quirk_class_validator(v) return - quirk_tok = value.rsplit(".", 1) - if len(quirk_tok) != 2: - # quirk_class is at least __module__.__class__ - raise ValueError(f"Invalid quirk class : '{value}'") + if value not in all_quirk_ids: + raise ValueError(f"Quirk ID '{value}' does not exist.") - if not find_quirk_class(zhaquirks, quirk_tok[0], quirk_tok[1]): - raise ValueError(f"Quirk class '{value}' does not exists.") + # get all quirk ID from zigpy quirks registry + all_quirk_ids = [] + for manufacturer in zigpy_quirks._DEVICE_REGISTRY._registry.values(): + for model_quirk_list in manufacturer.values(): + for quirk in model_quirk_list: + quirk_id = getattr(quirk, ATTR_QUIRK_ID, None) + if quirk_id is not None and quirk_id not in all_quirk_ids: + all_quirk_ids.append(quirk_id) + del quirk, model_quirk_list, manufacturer + # validate all quirk IDs used in component match rules for rule, _ in iter_all_rules(): - quirk_class_validator(rule.quirk_classes) + quirk_class_validator(rule.quirk_ids) def test_entity_names() -> None: @@ -596,6 +590,13 @@ def test_entity_names() -> None: if hasattr(entity, "_attr_name"): # The entity has a name assert isinstance(entity._attr_name, str) and entity._attr_name + elif hasattr(entity, "_attr_translation_key"): + assert ( + isinstance(entity._attr_translation_key, str) + and entity._attr_translation_key + ) + elif hasattr(entity, "_attr_device_class"): + assert entity._attr_device_class else: # The only exception (for now) is IASZone assert entity is IASZone diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index 18705168a3f..9c79578843c 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -1,17 +1,25 @@ """Test ZHA repairs.""" from collections.abc import Callable +from http import HTTPStatus import logging -from unittest.mock import patch +from unittest.mock import Mock, call, patch import pytest from universal_silabs_flasher.const import ApplicationType from universal_silabs_flasher.flasher import Flasher +from zigpy.application import ControllerApplication +import zigpy.backups +from zigpy.exceptions import NetworkSettingsInconsistent from homeassistant.components.homeassistant_sky_connect import ( DOMAIN as SKYCONNECT_DOMAIN, ) +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.components.zha.core.const import DOMAIN -from homeassistant.components.zha.repairs import ( +from homeassistant.components.zha.repairs.network_settings_inconsistent import ( + ISSUE_INCONSISTENT_NETWORK_SETTINGS, +) +from homeassistant.components.zha.repairs.wrong_silabs_firmware import ( DISABLE_MULTIPAN_URL, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, HardwareType, @@ -23,8 +31,10 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator SKYCONNECT_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0" @@ -98,7 +108,7 @@ async def test_multipan_firmware_repair( detected_hardware: HardwareType, expected_learn_more_url: str, config_entry: MockConfigEntry, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, ) -> None: """Test creating a repair when multi-PAN firmware is installed and probed.""" @@ -106,14 +116,14 @@ async def test_multipan_firmware_repair( # ZHA fails to set up with patch( - "homeassistant.components.zha.repairs.Flasher.probe_app_type", + "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", side_effect=set_flasher_app_type(ApplicationType.CPC), autospec=True, ), patch( "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", side_effect=RuntimeError(), ), patch( - "homeassistant.components.zha.repairs._detect_radio_hardware", + "homeassistant.components.zha.repairs.wrong_silabs_firmware._detect_radio_hardware", return_value=detected_hardware, ): await hass.config_entries.async_setup(config_entry.entry_id) @@ -136,9 +146,8 @@ async def test_multipan_firmware_repair( assert issue.learn_more_url == expected_learn_more_url # If ZHA manages to start up normally after this, the issue will be deleted - with mock_zigpy_connect: - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() issue = issue_registry.async_get_issue( domain=DOMAIN, @@ -156,7 +165,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( # ZHA fails to set up with patch( - "homeassistant.components.zha.repairs.Flasher.probe_app_type", + "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", side_effect=set_flasher_app_type(None), autospec=True, ), patch( @@ -182,7 +191,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( async def test_multipan_firmware_retry_on_probe_ezsp( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, ) -> None: """Test that ZHA is reloaded when EZSP firmware is probed.""" @@ -190,7 +199,7 @@ async def test_multipan_firmware_retry_on_probe_ezsp( # ZHA fails to set up with patch( - "homeassistant.components.zha.repairs.Flasher.probe_app_type", + "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", side_effect=set_flasher_app_type(ApplicationType.EZSP), autospec=True, ), patch( @@ -217,7 +226,8 @@ async def test_multipan_firmware_retry_on_probe_ezsp( async def test_no_warn_on_socket(hass: HomeAssistant) -> None: """Test that no warning is issued when the device is a socket.""" with patch( - "homeassistant.components.zha.repairs.probe_silabs_firmware_type", autospec=True + "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", + autospec=True, ) as mock_probe: await warn_on_wrong_silabs_firmware(hass, device="socket://1.2.3.4:5678") @@ -227,9 +237,163 @@ async def test_no_warn_on_socket(hass: HomeAssistant) -> None: async def test_probe_failure_exception_handling(caplog) -> None: """Test that probe failures are handled gracefully.""" with patch( - "homeassistant.components.zha.repairs.Flasher.probe_app_type", + "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", side_effect=RuntimeError(), ), caplog.at_level(logging.DEBUG): await probe_silabs_firmware_type("/dev/ttyZigbee") assert "Failed to probe application type" in caplog.text + + +async def test_inconsistent_settings_keep_new( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, + network_backup: zigpy.backups.NetworkBackup, +) -> None: + """Test inconsistent ZHA network settings: keep new settings.""" + + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + config_entry.add_to_hass(hass) + + new_state = network_backup.replace( + network_info=network_backup.network_info.replace(pan_id=0xBBBB) + ) + old_state = network_backup + + with patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=NetworkSettingsInconsistent( + message="Network settings are inconsistent", + new_state=new_state, + old_state=old_state, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + await hass.config_entries.async_unload(config_entry.entry_id) + + issue_registry = ir.async_get(hass) + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + ) + + # The issue is created + assert issue is not None + + client = await hass_client() + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"]["diff"] == "- PAN ID: `0x2DB4` → `0xBBBB`" + + mock_zigpy_connect.backups.add_backup = Mock() + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "use_new_settings"}, + ) + await hass.async_block_till_done() + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" + + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + ) + is None + ) + + assert mock_zigpy_connect.backups.add_backup.mock_calls == [call(new_state)] + + +async def test_inconsistent_settings_restore_old( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, + network_backup: zigpy.backups.NetworkBackup, +) -> None: + """Test inconsistent ZHA network settings: restore last backup.""" + + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + config_entry.add_to_hass(hass) + + new_state = network_backup.replace( + network_info=network_backup.network_info.replace(pan_id=0xBBBB) + ) + old_state = network_backup + + with patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=NetworkSettingsInconsistent( + message="Network settings are inconsistent", + new_state=new_state, + old_state=old_state, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + await hass.config_entries.async_unload(config_entry.entry_id) + + issue_registry = ir.async_get(hass) + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + ) + + # The issue is created + assert issue is not None + + client = await hass_client() + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"]["diff"] == "- PAN ID: `0x2DB4` → `0xBBBB`" + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "restore_old_settings"}, + ) + await hass.async_block_till_done() + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" + + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + ) + is None + ) + + assert mock_zigpy_connect.backups.restore_backup.mock_calls == [call(old_state)] diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index b9d9511a6d1..7c11077c55d 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -347,7 +347,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "active_power", + "power", async_test_electrical_measurement, 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, @@ -363,7 +363,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "rms_current", + "current", async_test_em_rms_current, 7, {"ac_current_divisor": 1000, "ac_current_multiplier": 1}, @@ -371,7 +371,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "rms_voltage", + "voltage", async_test_em_rms_voltage, 7, {"ac_voltage_divisor": 10, "ac_voltage_multiplier": 1}, @@ -613,9 +613,7 @@ 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 = find_entity_id( - Platform.SENSOR, zha_device, hass, qualifier="active_power" - ) + entity_id = "sensor.fakemanufacturer_fakemodel_power" # allow traffic to flow through the gateway and devices await async_enable_traffic(hass, [zha_device]) @@ -660,23 +658,23 @@ async def test_electrical_measurement_init( homeautomation.ElectricalMeasurement.cluster_id, {"apparent_power", "rms_voltage", "rms_current"}, { - "active_power", + "power", "ac_frequency", "power_factor", }, { "apparent_power", - "rms_voltage", - "rms_current", + "voltage", + "current", }, ), ( homeautomation.ElectricalMeasurement.cluster_id, {"apparent_power", "rms_current", "ac_frequency", "power_factor"}, - {"rms_voltage", "active_power"}, + {"voltage", "power"}, { "apparent_power", - "rms_current", + "current", "ac_frequency", "power_factor", }, @@ -685,10 +683,10 @@ async def test_electrical_measurement_init( homeautomation.ElectricalMeasurement.cluster_id, set(), { - "rms_voltage", - "active_power", + "voltage", + "power", "apparent_power", - "rms_current", + "current", "ac_frequency", "power_factor", }, @@ -909,7 +907,7 @@ async def test_elec_measurement_sensor_type( ) -> None: """Test ZHA electrical measurement sensor type.""" - entity_id = ENTITY_ID_PREFIX.format("active_power") + entity_id = ENTITY_ID_PREFIX.format("power") zigpy_dev = elec_measurement_zigpy_dev zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ "measurement_type" @@ -958,7 +956,7 @@ async def test_elec_measurement_skip_unsupported_attribute( ) -> None: """Test ZHA electrical measurement skipping update of unsupported attributes.""" - entity_id = ENTITY_ID_PREFIX.format("active_power") + entity_id = ENTITY_ID_PREFIX.format("power") zha_dev = elec_measurement_zha_dev cluster = zha_dev.device.endpoints[1].electrical_measurement diff --git a/tests/components/zha/test_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py index 4d11ae81b08..074484e6d24 100644 --- a/tests/components/zha/test_silabs_multiprotocol.py +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -44,11 +44,7 @@ async def test_async_get_channel_missing( zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() - with patch( - "bellows.zigbee.application.ControllerApplication.__new__", - return_value=zigpy_app_controller, - ): - assert await silabs_multiprotocol.async_get_channel(hass) is None + assert await silabs_multiprotocol.async_get_channel(hass) is None async def test_async_get_channel_no_zha(hass: HomeAssistant) -> None: @@ -74,26 +70,20 @@ async def test_change_channel( """Test changing the channel.""" await setup_zha() - with patch.object( - zigpy_app_controller, "move_network_to_channel", autospec=True - ) as mock_move_network_to_channel: - task = await silabs_multiprotocol.async_change_channel(hass, 20) - await task + task = await silabs_multiprotocol.async_change_channel(hass, 20) + await task - assert mock_move_network_to_channel.mock_calls == [call(20)] + assert zigpy_app_controller.move_network_to_channel.mock_calls == [call(20)] async def test_change_channel_no_zha( hass: HomeAssistant, zigpy_app_controller: ControllerApplication ) -> None: """Test changing the channel with no ZHA config entries and no database.""" - with patch.object( - zigpy_app_controller, "move_network_to_channel", autospec=True - ) as mock_move_network_to_channel: - task = await silabs_multiprotocol.async_change_channel(hass, 20) + task = await silabs_multiprotocol.async_change_channel(hass, 20) assert task is None - assert mock_move_network_to_channel.mock_calls == [] + assert zigpy_app_controller.mock_calls == [] @pytest.mark.parametrize(("delay", "sleep"), [(0, 0), (5, 0), (15, 15 - 10.27)]) @@ -107,13 +97,11 @@ async def test_change_channel_delay( """Test changing the channel with a delay.""" await setup_zha() - with patch.object( - zigpy_app_controller, "move_network_to_channel", autospec=True - ) as mock_move_network_to_channel, patch( + with patch( "homeassistant.components.zha.silabs_multiprotocol.asyncio.sleep", autospec=True ) as mock_sleep: task = await silabs_multiprotocol.async_change_channel(hass, 20, delay=delay) await task - assert mock_move_network_to_channel.mock_calls == [call(20)] + assert zigpy_app_controller.move_network_to_channel.mock_calls == [call(20)] assert mock_sleep.mock_calls == [call(sleep)] diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index b0e15a01318..d914c88c0c2 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -6,6 +6,7 @@ from copy import deepcopy from typing import TYPE_CHECKING from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from freezegun import freeze_time import pytest import voluptuous as vol import zigpy.backups @@ -227,6 +228,7 @@ async def test_device_cluster_commands(zha_client) -> None: assert command[TYPE] is not None +@freeze_time("2023-09-23 20:16:00+00:00") async def test_list_devices(zha_client) -> None: """Test getting ZHA devices.""" await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index bba5ee124ba..44f01555b19 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -100,7 +100,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-5-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-5-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -196,7 +196,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -206,12 +206,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -319,7 +319,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -374,7 +374,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -429,7 +429,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -484,7 +484,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -603,7 +603,7 @@ DEVICES = [ 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" + "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_ias_zone" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { @@ -678,7 +678,7 @@ DEVICES = [ 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" + "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_ias_zone" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { @@ -764,7 +764,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_smokesensor_em_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_smokesensor_em_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -832,7 +832,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_co_v16_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_co_v16_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -895,7 +895,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -933,7 +933,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-6-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-6-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -1831,7 +1831,7 @@ DEVICES = [ }, ("fan", "00:11:22:33:44:55:66:77-1-514"): { DEV_SIG_CLUSTER_HANDLERS: ["fan"], - DEV_SIG_ENT_MAP_CLASS: "ZhaFan", + DEV_SIG_ENT_MAP_CLASS: "KofFan", DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_fan", }, }, @@ -2081,17 +2081,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { 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-power_factor"): { - DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_power_factor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], @@ -2156,7 +2151,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -2166,12 +2161,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -3205,7 +3200,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { 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", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_wleak_aq1_ias_zone", }, ("sensor", "00:11:22:33:44:55:66:77-1-2"): { DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], @@ -3378,7 +3373,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -3421,7 +3416,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -3629,9 +3624,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { 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" - ), + DEV_SIG_ENT_MAP_ID: ("sensor.osram_lightify_rt_tunable_white_power"), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -3643,16 +3636,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_current"): { 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" - ), + DEV_SIG_ENT_MAP_ID: ("sensor.osram_lightify_rt_tunable_white_current"), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_voltage"): { 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" - ), + DEV_SIG_ENT_MAP_ID: ("sensor.osram_lightify_rt_tunable_white_voltage"), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -3881,7 +3870,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -3929,7 +3918,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -3982,7 +3971,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -4035,7 +4024,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4045,12 +4034,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4098,7 +4087,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { 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", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_dws04n_sf_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -4163,7 +4152,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4173,12 +4162,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4231,7 +4220,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -4289,7 +4278,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4301,12 +4290,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4374,7 +4363,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4386,12 +4375,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4464,7 +4453,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4476,12 +4465,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4544,7 +4533,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4554,12 +4543,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4731,7 +4720,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -4896,7 +4885,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -5526,7 +5515,7 @@ DEVICES = [ ("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", + DEV_SIG_ENT_MAP_ID: "select.philips_sml001_motion_sensitivity", }, }, }, diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 606dda30b24..f4d7ea0a754 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -26,7 +26,7 @@ DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any" NOTIFICATION_MOTION_BINARY_SENSOR = "binary_sensor.multisensor_6_motion_detection" NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_status" INDICATOR_SENSOR = "sensor.z_wave_thermostat_indicator_value" -BASIC_NUMBER_ENTITY = "number.livingroomlight_basic" +BASIC_LIGHT_ENTITY = "light.livingroomlight_basic" PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( "binary_sensor.august_smart_lock_pro_3rd_gen_the_current_status_of_the_door" ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index b9feeab1f2f..5a424b38c5b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -662,18 +662,28 @@ def logic_group_zdb5100_state_fixture(): return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) -@pytest.fixture(name="climate_intermatic_pe653_state", scope="session") -def climate_intermatic_pe653_state_fixture(): - """Load Intermatic PE653 Pool Control node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_intermatic_pe653_state.json")) +@pytest.fixture(name="central_scene_node_state", scope="session") +def central_scene_node_state_fixture(): + """Load node with Central Scene CC node state fixture data.""" + return json.loads(load_fixture("zwave_js/central_scene_node_state.json")) # model fixtures +@pytest.fixture(name="listen_block") +def mock_listen_block_fixture(): + """Mock a listen block.""" + return asyncio.Event() + + @pytest.fixture(name="client") def mock_client_fixture( - controller_state, controller_node_state, version_state, log_config_state + controller_state, + controller_node_state, + version_state, + log_config_state, + listen_block, ): """Mock a client.""" with patch( @@ -687,9 +697,7 @@ def mock_client_fixture( async def listen(driver_ready: asyncio.Event) -> None: driver_ready.set() - listen_block = asyncio.Event() await listen_block.wait() - pytest.fail("Listen wasn't canceled!") async def disconnect(): client.connected = False @@ -1298,9 +1306,9 @@ def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): return node -@pytest.fixture(name="climate_intermatic_pe653") -def climate_intermatic_pe653_fixture(client, climate_intermatic_pe653_state): - """Mock an Intermatic PE653 node.""" - node = Node(client, copy.deepcopy(climate_intermatic_pe653_state)) +@pytest.fixture(name="central_scene_node") +def central_scene_node_fixture(client, central_scene_node_state): + """Mock a node with the Central Scene CC.""" + node = Node(client, copy.deepcopy(central_scene_node_state)) client.driver.controller.nodes[node.node_id] = node return node diff --git a/tests/components/zwave_js/fixtures/central_scene_node_state.json b/tests/components/zwave_js/fixtures/central_scene_node_state.json new file mode 100644 index 00000000000..1fb01275ccf --- /dev/null +++ b/tests/components/zwave_js/fixtures/central_scene_node_state.json @@ -0,0 +1,431 @@ +{ + "nodeId": 51, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "firmwareVersion": "1.3.0", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 51, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.81" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.3"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.81.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.3.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 255 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "statistics": { + "commandsTX": 42, + "commandsRX": 46, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 55.4, + "rssi": -72, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -72, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 0, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json b/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json deleted file mode 100644 index a5e86b9c013..00000000000 --- a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json +++ /dev/null @@ -1,4508 +0,0 @@ -{ - "nodeId": 19, - "index": 0, - "status": 4, - "ready": true, - "isListening": true, - "isRouting": true, - "isSecure": false, - "manufacturerId": 5, - "productId": 1619, - "productType": 20549, - "firmwareVersion": "3.9", - "deviceConfig": { - "filename": "/data/db/devices/0x0005/pe653.json", - "isEmbedded": true, - "manufacturer": "Intermatic", - "manufacturerId": 5, - "label": "PE653", - "description": "Pool Control", - "devices": [ - { - "productType": 20549, - "productId": 1619 - } - ], - "firmwareVersion": { - "min": "0.0", - "max": "255.255" - }, - "preferred": false, - "associations": {}, - "paramInformation": { - "_map": {} - }, - "compat": { - "addCCs": {}, - "overrideQueries": { - "overrides": {} - } - } - }, - "label": "PE653", - "endpointCountIsDynamic": false, - "endpointsHaveIdenticalCapabilities": false, - "individualEndpointCount": 39, - "aggregatedEndpointCount": 0, - "interviewAttempts": 1, - "endpoints": [ - { - "nodeId": 19, - "index": 0, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 96, - "name": "Multi Channel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 2, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 3, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 4, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 5, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 6, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 7, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 8, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 9, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 10, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 11, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 12, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 13, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 14, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 15, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 16, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 17, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 18, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 19, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 20, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 21, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 22, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 23, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 24, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 25, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 26, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 27, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 28, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 29, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 30, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 31, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 32, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 33, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 34, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 35, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 36, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 37, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 38, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 39, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - } - ], - "values": [ - { - "endpoint": 0, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 7, - "propertyName": "setpoint", - "propertyKeyName": "Furnace", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Furnace)", - "ccSpecific": { - "setpointType": 7 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 60 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 2, - "propertyName": "Installed Pump Type", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Installed Pump Type", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "One Speed", - "1": "Two Speed" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Installed Pump Type" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 1, - "propertyName": "Booster (Cleaner) Pump Installed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Booster (Cleaner) Pump Installed", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Installed" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 65280, - "propertyName": "Booster (Cleaner) Pump Operation Mode", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Set the filter pump mode to use when the booster (cleaner) pump is running.", - "label": "Booster (Cleaner) Pump Operation Mode", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disable", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Operation Mode", - "info": "Set the filter pump mode to use when the booster (cleaner) pump is running." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 65280, - "propertyName": "Heater Cooldown Period", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Heater Cooldown Period", - "default": -1, - "min": -1, - "max": 15, - "states": { - "0": "Heater installed with no cooldown", - "-1": "No heater installed" - }, - "unit": "minutes", - "valueSize": 2, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Heater Cooldown Period" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 1, - "propertyName": "Heater Safety Setting", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Prevent the heater from turning on while the pump is off.", - "label": "Heater Safety Setting", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Heater Safety Setting", - "info": "Prevent the heater from turning on while the pump is off." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 4278190080, - "propertyName": "Water Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Water Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Water Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 16711680, - "propertyName": "Air/Freeze Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Air/Freeze Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Air/Freeze Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 65280, - "propertyName": "Solar Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Solar Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Solar Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 22, - "propertyName": "Pool/Spa Configuration", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Pool/Spa Configuration", - "default": 0, - "min": 0, - "max": 2, - "states": { - "0": "Pool", - "1": "Spa", - "2": "Both" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Pool/Spa Configuration" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 23, - "propertyName": "Spa Mode Pump Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration.", - "label": "Spa Mode Pump Speed", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disabled", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Spa Mode Pump Speed", - "info": "Requires pool/spa configuration." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 32, - "propertyName": "Variable Speed Pump - Speed 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 1", - "default": 750, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 1", - "info": "Requires connected variable speed pump." - }, - "value": 1400 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 33, - "propertyName": "Variable Speed Pump - Speed 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 2", - "default": 1500, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 2", - "info": "Requires connected variable speed pump." - }, - "value": 1700 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 34, - "propertyName": "Variable Speed Pump - Speed 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 3", - "default": 2350, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 3", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 35, - "propertyName": "Variable Speed Pump - Speed 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 4", - "default": 3110, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 4", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 49, - "propertyName": "Variable Speed Pump - Max Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Max Speed", - "default": 3450, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Max Speed", - "info": "Requires connected variable speed pump." - }, - "value": 3000 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 4278190080, - "propertyName": "Freeze Protection: Temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Temperature", - "default": 0, - "min": 0, - "max": 44, - "states": { - "0": "Disabled", - "40": "40 °F", - "41": "41 °F", - "42": "42 °F", - "43": "43 °F", - "44": "44 °F" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Temperature" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65536, - "propertyName": "Freeze Protection: Turn On Circuit 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 1", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 1" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 131072, - "propertyName": "Freeze Protection: Turn On Circuit 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 2", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 2" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 262144, - "propertyName": "Freeze Protection: Turn On Circuit 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 3", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 3" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 524288, - "propertyName": "Freeze Protection: Turn On Circuit 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 4", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 4" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 1048576, - "propertyName": "Freeze Protection: Turn On Circuit 5", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 5", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 5" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65280, - "propertyName": "Freeze Protection: Turn On VSP Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires variable speed pump and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On VSP Speed", - "default": 0, - "min": 0, - "max": 5, - "states": { - "0": "None", - "2": "VSP Speed 1", - "3": "VSP Speed 2", - "4": "VSP Speed 3", - "5": "VSP Speed 4" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On VSP Speed", - "info": "Requires variable speed pump and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 128, - "propertyName": "Freeze Protection: Turn On Heater", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires heater and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On Heater", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Heater", - "info": "Requires heater and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 127, - "propertyName": "Freeze Protection: Pool/Spa Cycle Time", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration and connected air/freeze sensor.", - "label": "Freeze Protection: Pool/Spa Cycle Time", - "default": 0, - "min": 0, - "max": 30, - "unit": "minutes", - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Freeze Protection: Pool/Spa Cycle Time", - "info": "Requires pool/spa configuration and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 4, - "propertyName": "Circuit 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable.", - "label": "Circuit 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 1", - "info": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable." - }, - "value": 1979884035 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 5, - "propertyName": "Circuit 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 6, - "propertyName": "Circuit 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 7, - "propertyName": "Circuit 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 8, - "propertyName": "Circuit 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 9, - "propertyName": "Circuit 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 10, - "propertyName": "Circuit 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 11, - "propertyName": "Circuit 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 12, - "propertyName": "Circuit 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 13, - "propertyName": "Circuit 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 14, - "propertyName": "Circuit 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 15, - "propertyName": "Circuit 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 16, - "propertyName": "Circuit 5 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 17, - "propertyName": "Circuit 5 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 18, - "propertyName": "Circuit 5 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 19, - "propertyName": "Pool/Spa Mode Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 20, - "propertyName": "Pool/Spa Mode Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 21, - "propertyName": "Pool/Spa Mode Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 36, - "propertyName": "Variable Speed Pump Speed 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 37, - "propertyName": "Variable Speed Pump Speed 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 38, - "propertyName": "Variable Speed Pump Speed 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 39, - "propertyName": "Variable Speed Pump Speed 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 1476575235 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 40, - "propertyName": "Variable Speed Pump Speed 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 41, - "propertyName": "Variable Speed Pump Speed 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 42, - "propertyName": "Variable Speed Pump Speed 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 43, - "propertyName": "Variable Speed Pump Speed 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 44, - "propertyName": "Variable Speed Pump Speed 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 45, - "propertyName": "Variable Speed Pump Speed 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 46, - "propertyName": "Variable Speed Pump Speed 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 47, - "propertyName": "Variable Speed Pump Speed 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "manufacturerId", - "propertyName": "manufacturerId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Manufacturer ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 5 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productType", - "propertyName": "productType", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product type", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 20549 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productId", - "propertyName": "productId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 1619 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "libraryType", - "propertyName": "libraryType", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Library type", - "states": { - "0": "Unknown", - "1": "Static Controller", - "2": "Controller", - "3": "Enhanced Slave", - "4": "Slave", - "5": "Installer", - "6": "Routing Slave", - "7": "Bridge Controller", - "8": "Device under Test", - "9": "N/A", - "10": "AV Remote", - "11": "AV Device" - }, - "stateful": true, - "secret": false - }, - "value": 6 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "protocolVersion", - "propertyName": "protocolVersion", - "ccVersion": 1, - "metadata": { - "type": "string", - "readable": true, - "writeable": false, - "label": "Z-Wave protocol version", - "stateful": true, - "secret": false - }, - "value": "2.78" - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "firmwareVersions", - "propertyName": "firmwareVersions", - "ccVersion": 1, - "metadata": { - "type": "string[]", - "readable": true, - "writeable": false, - "label": "Z-Wave chip firmware versions", - "stateful": true, - "secret": false - }, - "value": ["3.9"] - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 48, - "commandClassName": "Binary Sensor", - "property": "Any", - "propertyName": "Any", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Sensor state (Any)", - "ccSpecific": { - "sensorType": 255 - }, - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 81, - "nodeId": 19 - }, - { - "endpoint": 1, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 1, - "propertyName": "setpoint", - "propertyKeyName": "Heating", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Heating)", - "ccSpecific": { - "setpointType": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 39 - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 2, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 84, - "nodeId": 19 - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 86, - "nodeId": 19 - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 80, - "nodeId": 19 - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 5, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 83, - "nodeId": 19 - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - } - ], - "isFrequentListening": false, - "maxDataRate": 40000, - "supportedDataRates": [40000], - "protocolVersion": 2, - "supportsBeaming": true, - "supportsSecurity": false, - "nodeType": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "interviewStage": "Complete", - "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0005:0x5045:0x0653:3.9", - "highestSecurityClass": -1, - "isControllerNode": false, - "keepAwake": false -} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 965b1ea4f1b..9c4a6339a78 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -4650,3 +4650,86 @@ async def test_subscribe_node_statistics( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_hard_reset_controller( + hass: HomeAssistant, + client, + integration, + listen_block, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the hard_reset_controller WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} + ) + + client.async_send_command.return_value = {} + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + + listen_block.set() + listen_block.clear() + await hass.async_block_till_done() + + msg = await ws_client.receive_json() + assert msg["result"] == device.id + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == {"command": "driver.hard_reset"} + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.driver.Driver.async_hard_reset", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: "INVALID", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index cdc1e9959a7..e9040dfd397 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -792,196 +792,3 @@ async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" in caplog.text ) - - -async def test_multi_setpoint_thermostat( - hass: HomeAssistant, client, climate_intermatic_pe653, integration -) -> None: - """Test a thermostat with multiple setpoints.""" - node = climate_intermatic_pe653 - - heating_entity_id = "climate.pool_control_2" - heating = hass.states.get(heating_entity_id) - assert heating - assert heating.state == HVACMode.HEAT - assert heating.attributes[ATTR_TEMPERATURE] == 3.9 - assert heating.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - heating.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - furnace_entity_id = "climate.pool_control" - furnace = hass.states.get(furnace_entity_id) - assert furnace - assert furnace.state == HVACMode.HEAT - assert furnace.attributes[ATTR_TEMPERATURE] == 15.6 - assert furnace.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - furnace.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - client.async_send_command_no_wait.reset_mock() - - # Test setting temperature of heating setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_TEMPERATURE: 20.0, - }, - blocking=True, - ) - - # Test setting temperature of furnace setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_TEMPERATURE: 2.0, - }, - blocking=True, - ) - - # Test setting illegal mode raises an error - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 1, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 1, - } - assert args["value"] == 68.0 - - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 0, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 7, - } - assert args["value"] == 35.6 - - client.async_send_command.reset_mock() - - # Test heating setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 1, - "property": "setpoint", - "propertyKey": 1, - "propertyKeyName": "Heating", - "propertyName": "setpoint", - "newValue": 23, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - # furnace not changed - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 15.6 - - client.async_send_command.reset_mock() - - # Test furnace setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 0, - "property": "setpoint", - "propertyKey": 7, - "propertyKeyName": "Furnace", - "propertyName": "setpoint", - "newValue": 68, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - # heating not changed - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 20 - - client.async_send_command.reset_mock() diff --git a/tests/components/zwave_js/test_event.py b/tests/components/zwave_js/test_event.py new file mode 100644 index 00000000000..12187d3d227 --- /dev/null +++ b/tests/components/zwave_js/test_event.py @@ -0,0 +1,175 @@ +"""Test the Z-Wave JS event platform.""" +from datetime import timedelta + +from freezegun import freeze_time +from zwave_js_server.event import Event + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.zwave_js.const import ATTR_VALUE +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +BASIC_EVENT_VALUE_ENTITY = "event.honeywell_in_wall_smart_fan_control_event_value" +CENTRAL_SCENE_ENTITY = "event.node_51_scene_002" + + +async def test_basic( + hass: HomeAssistant, client, fan_honeywell_39358, integration +) -> None: + """Test the Basic CC event entity.""" + dt_util.now() + fut = dt_util.now() + timedelta(minutes=1) + node = fan_honeywell_39358 + state = hass.states.get(BASIC_EVENT_VALUE_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN + + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "commandClassName": "Basic", + "commandClass": 32, + "endpoint": 0, + "property": "event", + "propertyName": "event", + "value": 255, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "min": 0, + "max": 255, + "label": "Event value", + }, + "ccVersion": 1, + }, + }, + ) + with freeze_time(fut): + node.receive_event(event) + + state = hass.states.get(BASIC_EVENT_VALUE_ENTITY) + + assert state + assert state.state == dt_util.as_utc(fut).isoformat(timespec="milliseconds") + attributes = state.attributes + assert attributes[ATTR_EVENT_TYPE] == "Basic event value" + assert attributes[ATTR_VALUE] == 255 + + +async def test_central_scene( + hass: HomeAssistant, client, central_scene_node, integration +) -> None: + """Test the Central Scene CC event entity.""" + dt_util.now() + fut = dt_util.now() + timedelta(minutes=1) + node = central_scene_node + state = hass.states.get(CENTRAL_SCENE_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN + + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x", + }, + "stateful": False, + "secret": False, + }, + "value": 1, + }, + }, + ) + with freeze_time(fut): + node.receive_event(event) + + state = hass.states.get(CENTRAL_SCENE_ENTITY) + + assert state + assert state.state == dt_util.as_utc(fut).isoformat(timespec="milliseconds") + attributes = state.attributes + assert attributes[ATTR_EVENT_TYPE] == "KeyReleased" + assert attributes[ATTR_VALUE] == 1 + + # Try invalid value + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x", + }, + "stateful": False, + "secret": False, + }, + }, + }, + ) + with freeze_time(fut + timedelta(minutes=10)): + node.receive_event(event) + + # Nothing should have changed even though the time has changed + state = hass.states.get(CENTRAL_SCENE_ENTITY) + + assert state + assert state.state == dt_util.as_utc(fut).isoformat(timespec="milliseconds") + attributes = state.attributes + assert attributes[ATTR_EVENT_TYPE] == "KeyReleased" + assert attributes[ATTR_VALUE] == 1 diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 80b179248d8..4fbaa97f118 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -1,4 +1,4 @@ -"""Test Z-Wave JS (value notification) events.""" +"""Test Z-Wave JS events.""" from unittest.mock import AsyncMock import pytest diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 1203997839e..c57e3b1f868 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1644,3 +1644,61 @@ async def test_server_logging(hass: HomeAssistant, client) -> None: assert len(client.async_send_command.call_args_list) == 0 assert not client.enable_server_logging.called assert not client.disable_server_logging.called + + +async def test_factory_reset_node( + hass: HomeAssistant, client, multisensor_6, multisensor_6_state, integration +) -> None: + """Test when a node is removed because it was reset.""" + # One config entry scenario + remove_event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "reason": 5, + "node": deepcopy(multisensor_6_state), + }, + ) + dev_id = get_device_id(client.driver, multisensor_6) + msg_id = f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}" + + client.driver.controller.receive_event(remove_event) + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + assert list(notifications)[0] == msg_id + assert notifications[msg_id]["message"].startswith("`Multisensor 6`") + assert "with the home ID" not in notifications[msg_id]["message"] + async_dismiss(hass, msg_id) + + # Add mock config entry to simulate having multiple entries + new_entry = MockConfigEntry(domain=DOMAIN) + new_entry.add_to_hass(hass) + + # Re-add the node then remove it again + client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( + client, deepcopy(multisensor_6_state) + ) + remove_event.data["node"] = deepcopy(multisensor_6_state) + client.driver.controller.receive_event(remove_event) + # Test case where config entry title and home ID don't match + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + assert list(notifications)[0] == msg_id + assert ( + "network `Mock Title`, with the home ID `3245146787`." + in notifications[msg_id]["message"] + ) + async_dismiss(hass, msg_id) + + # Test case where config entry title and home ID do match + hass.config_entries.async_update_entry(integration, title="3245146787") + client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( + client, deepcopy(multisensor_6_state) + ) + remove_event.data["node"] = deepcopy(multisensor_6_state) + client.driver.controller.receive_event(remove_event) + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + assert list(notifications)[0] == msg_id + assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 4b0345b00ea..f5b53f6a76e 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -26,9 +26,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .common import ( AEON_SMART_SWITCH_LIGHT_ENTITY, + BASIC_LIGHT_ENTITY, BULB_6_MULTI_COLOR_LIGHT_ENTITY, EATON_RF9640_ENTITY, ZEN_31_ENTITY, @@ -127,7 +129,7 @@ async def test_light( assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_COLOR_TEMP] == 370 - assert ATTR_RGB_COLOR in state.attributes + assert state.attributes[ATTR_RGB_COLOR] is not None # Test turning on with same brightness await hass.services.async_call( @@ -252,7 +254,7 @@ async def test_light( assert state.attributes[ATTR_COLOR_MODE] == "hs" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_RGB_COLOR] == (255, 76, 255) - assert ATTR_COLOR_TEMP not in state.attributes + assert state.attributes[ATTR_COLOR_TEMP] is None client.async_send_command.reset_mock() @@ -430,8 +432,8 @@ async def test_light( state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) assert state.state == STATE_UNKNOWN - assert ATTR_COLOR_MODE not in state.attributes - assert ATTR_BRIGHTNESS not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None + assert state.attributes[ATTR_BRIGHTNESS] is None async def test_v4_dimmer_light( @@ -859,3 +861,144 @@ async def test_black_is_off_zdb5100( "property": "targetColor", } assert args["value"] == {"red": 255, "green": 76, "blue": 255} + + +async def test_basic_cc_light( + hass: HomeAssistant, client, ge_in_wall_dimmer_switch, integration +) -> None: + """Test light is created from Basic CC.""" + node = ge_in_wall_dimmer_switch + + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(BASIC_LIGHT_ENTITY) + + assert entity_entry + assert not entity_entry.disabled + + state = hass.states.get(BASIC_LIGHT_ENTITY) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes["supported_features"] == 0 + + # Send value to 0 + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 2, + "args": { + "commandClassName": "Basic", + "commandClass": 32, + "endpoint": 0, + "property": "currentValue", + "newValue": 0, + "prevValue": None, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(BASIC_LIGHT_ENTITY) + assert state + assert state.state == STATE_OFF + + # Turn on light + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": BASIC_LIGHT_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 32, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 255 + + # Due to optimistic updates, the state should be on even though the Z-Wave state + # hasn't been updated yet + state = hass.states.get(BASIC_LIGHT_ENTITY) + + assert state + assert state.state == STATE_ON + + client.async_send_command.reset_mock() + + # Send value to 0 + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 2, + "args": { + "commandClassName": "Basic", + "commandClass": 32, + "endpoint": 0, + "property": "currentValue", + "newValue": 0, + "prevValue": None, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(BASIC_LIGHT_ENTITY) + assert state + assert state.state == STATE_OFF + + # Turn on light with brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": BASIC_LIGHT_ENTITY, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 32, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 50 + + # Since we specified a brightness, there is no optimistic update so the state + # should be off + state = hass.states.get(BASIC_LIGHT_ENTITY) + + assert state + assert state.state == STATE_OFF + + client.async_send_command.reset_mock() + + # Turn off light + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": BASIC_LIGHT_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 32, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 0 diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index 7a3ffbda589..b05d9e46f73 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -9,8 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import BASIC_NUMBER_ENTITY - from tests.common import MockConfigEntry NUMBER_ENTITY = "number.thermostat_hvac_valve_control" @@ -219,18 +217,6 @@ async def test_volume_number( assert state.state == STATE_UNKNOWN -async def test_disabled_basic_number( - hass: HomeAssistant, ge_in_wall_dimmer_switch, integration -) -> None: - """Test number is created from Basic CC and is disabled.""" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(BASIC_NUMBER_ENTITY) - - assert entity_entry - assert entity_entry.disabled - assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - async def test_config_parameter_number( hass: HomeAssistant, climate_adc_t3000, integration ) -> None: diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index d452f28b3bf..f00413b0d80 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -130,6 +130,40 @@ async def test_numeric_sensor( assert state.state == "0" +async def test_invalid_multilevel_sensor_scale( + hass: HomeAssistant, client, multisensor_6_state, integration +) -> None: + """Test a multilevel sensor with an invalid scale.""" + node_state = copy.deepcopy(multisensor_6_state) + value = next( + value + for value in node_state["values"] + if value["commandClass"] == 49 and value["property"] == "Air temperature" + ) + value["metadata"]["ccSpecific"]["scale"] = -1 + value["metadata"]["unit"] = None + + event = Event( + "node added", + { + "source": "controller", + "event": "node added", + "node": node_state, + "result": "", + }, + ) + client.driver.controller.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(AIR_TEMPERATURE_SENSOR) + + assert state + assert state.state == "9.0" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_STATE_CLASS not in state.attributes + + async def test_energy_sensors( hass: HomeAssistant, hank_binary_switch, integration ) -> None: @@ -424,10 +458,7 @@ async def test_node_status_sensor_not_ready( async def test_reset_meter( - hass: HomeAssistant, - client, - aeon_smart_switch_6, - integration, + hass: HomeAssistant, client, aeon_smart_switch_6, integration ) -> None: """Test reset_meter service.""" client.async_send_command.return_value = {} @@ -487,10 +518,7 @@ async def test_reset_meter( async def test_meter_attributes( - hass: HomeAssistant, - client, - aeon_smart_switch_6, - integration, + hass: HomeAssistant, client, aeon_smart_switch_6, integration ) -> None: """Test meter entity attributes.""" state = hass.states.get(METER_ENERGY_SENSOR) @@ -501,6 +529,42 @@ async def test_meter_attributes( assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING +async def test_invalid_meter_scale( + hass: HomeAssistant, client, aeon_smart_switch_6_state, integration +) -> None: + """Test a meter sensor with an invalid scale.""" + node_state = copy.deepcopy(aeon_smart_switch_6_state) + value = next( + value + for value in node_state["values"] + if value["commandClass"] == 50 + and value["property"] == "value" + and value["propertyKey"] == 65537 + ) + value["metadata"]["ccSpecific"]["scale"] = -1 + value["metadata"]["unit"] = None + + event = Event( + "node added", + { + "source": "controller", + "event": "node added", + "node": node_state, + "result": "", + }, + ) + client.driver.controller.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(METER_ENERGY_SENSOR) + assert state + assert state.attributes[ATTR_METER_TYPE] == MeterType.ELECTRIC.value + assert state.attributes[ATTR_METER_TYPE_NAME] == MeterType.ELECTRIC.name + assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + async def test_special_meters( hass: HomeAssistant, aeon_smart_switch_6_state, client, integration ) -> None: diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 210339e22d7..6df5881107a 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -9,7 +9,7 @@ from homeassistant.components.siren import ( from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -SIREN_ENTITY = "siren.indoor_siren_6_2" +SIREN_ENTITY = "siren.indoor_siren_6_play_tone_2" TONE_ID_VALUE_ID = { "endpoint": 2, diff --git a/tests/conftest.py b/tests/conftest.py index f743a2fe96a..09ad70bfcf1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -376,6 +376,13 @@ def verify_cleanup( ) +@pytest.fixture(autouse=True) +def reset_hass_threading_local_object() -> Generator[None, None, None]: + """Reset the _Hass threading.local object for every test case.""" + yield + ha._hass.__dict__.clear() + + @pytest.fixture(autouse=True) def bcrypt_cost() -> Generator[None, None, None]: """Run with reduced rounds during tests, to speed up uses.""" @@ -1515,33 +1522,6 @@ async def recorder_mock( return await async_setup_recorder_instance(hass, recorder_config) -@pytest.fixture -def mock_integration_frame() -> Generator[Mock, None, None]: - """Mock as if we're calling code from inside an integration.""" - correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", - lineno="23", - line="self.light.is_on", - ) - with patch( - "homeassistant.helpers.frame.extract_stack", - return_value=[ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - correct_frame, - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ], - ): - yield correct_frame - - @pytest.fixture(name="enable_bluetooth") async def mock_enable_bluetooth( hass: HomeAssistant, diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index fe7ffca9a47..46b389722e8 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -21,7 +21,7 @@ from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE, HomeAssistant import homeassistant.helpers.aiohttp_client as client from homeassistant.util.color import RGBColor -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockModule, mock_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -52,26 +52,53 @@ def camera_client_fixture(hass, hass_client): async def test_get_clientsession_with_ssl(hass: HomeAssistant) -> None: """Test init clientsession with ssl.""" client.async_get_clientsession(hass) + verify_ssl = True + family = 0 - assert isinstance(hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) async def test_get_clientsession_without_ssl(hass: HomeAssistant) -> None: """Test init clientsession without ssl.""" client.async_get_clientsession(hass, verify_ssl=False) + verify_ssl = False + family = 0 - assert isinstance( - hass.data[client.DATA_CLIENTSESSION_NOTVERIFY], aiohttp.ClientSession - ) - assert isinstance(hass.data[client.DATA_CONNECTOR_NOTVERIFY], aiohttp.TCPConnector) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) + + +@pytest.mark.parametrize( + ("verify_ssl", "expected_family"), + [(True, 0), (False, 0), (True, 4), (False, 4), (True, 6), (False, 6)], +) +async def test_get_clientsession( + hass: HomeAssistant, verify_ssl: bool, expected_family: int +) -> None: + """Test init clientsession combinations.""" + client.async_get_clientsession(hass, verify_ssl=verify_ssl, family=expected_family) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, expected_family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, expected_family)] + assert isinstance(connector, aiohttp.TCPConnector) async def test_create_clientsession_with_ssl_and_cookies(hass: HomeAssistant) -> None: """Test create clientsession with ssl.""" session = client.async_create_clientsession(hass, cookies={"bla": True}) assert isinstance(session, aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + + verify_ssl = True + family = 0 + + assert client.DATA_CLIENTSESSION not in hass.data + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) async def test_create_clientsession_without_ssl_and_cookies( @@ -80,46 +107,53 @@ async def test_create_clientsession_without_ssl_and_cookies( """Test create clientsession without ssl.""" session = client.async_create_clientsession(hass, False, cookies={"bla": True}) assert isinstance(session, aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR_NOTVERIFY], aiohttp.TCPConnector) + + verify_ssl = False + family = 0 + + assert client.DATA_CLIENTSESSION not in hass.data + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) -async def test_get_clientsession_cleanup(hass: HomeAssistant) -> None: - """Test init clientsession with ssl.""" - client.async_get_clientsession(hass) +@pytest.mark.parametrize( + ("verify_ssl", "expected_family"), + [(True, 0), (False, 0), (True, 4), (False, 4), (True, 6), (False, 6)], +) +async def test_get_clientsession_cleanup( + hass: HomeAssistant, verify_ssl: bool, expected_family: int +) -> None: + """Test init clientsession cleanup.""" + client.async_get_clientsession(hass, verify_ssl=verify_ssl, family=expected_family) - assert isinstance(hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, expected_family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, expected_family)] + assert isinstance(connector, aiohttp.TCPConnector) hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) await hass.async_block_till_done() - assert hass.data[client.DATA_CLIENTSESSION].closed - assert hass.data[client.DATA_CONNECTOR].closed - - -async def test_get_clientsession_cleanup_without_ssl(hass: HomeAssistant) -> None: - """Test init clientsession with ssl.""" - client.async_get_clientsession(hass, verify_ssl=False) - - assert isinstance( - hass.data[client.DATA_CLIENTSESSION_NOTVERIFY], aiohttp.ClientSession - ) - assert isinstance(hass.data[client.DATA_CONNECTOR_NOTVERIFY], aiohttp.TCPConnector) - - hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) - await hass.async_block_till_done() - - assert hass.data[client.DATA_CLIENTSESSION_NOTVERIFY].closed - assert hass.data[client.DATA_CONNECTOR_NOTVERIFY].closed + assert client_session.closed + assert connector.closed async def test_get_clientsession_patched_close(hass: HomeAssistant) -> None: """Test closing clientsession does not work.""" + + verify_ssl = True + family = 0 + with patch("aiohttp.ClientSession.close") as mock_close: session = client.async_get_clientsession(hass) - assert isinstance(hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + assert isinstance( + hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)], + aiohttp.ClientSession, + ) + assert isinstance( + hass.data[client.DATA_CONNECTOR][(verify_ssl, family)], aiohttp.TCPConnector + ) with pytest.raises(RuntimeError): await session.close() @@ -155,9 +189,10 @@ async def test_warning_close_session_integration( session = client.async_get_clientsession(hass) await session.close() assert ( - "Detected integration that closes the Home Assistant aiohttp session. " - "Please report issue for hue using this method at " - "homeassistant/components/hue/light.py, line 23: await session.close()" + "Detected that integration 'hue' closes the Home Assistant aiohttp session at " + "homeassistant/components/hue/light.py, line 23: await session.close(), " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text @@ -166,6 +201,7 @@ async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test log warning message when closing the session from custom context.""" + mock_integration(hass, MockModule("hue"), built_in=False) with patch( "homeassistant.helpers.frame.extract_stack", return_value=[ @@ -189,10 +225,10 @@ async def test_warning_close_session_custom( session = client.async_get_clientsession(hass) await session.close() assert ( - "Detected integration that closes the Home Assistant aiohttp session. Please" - " report issue to the custom integration author for hue using this method at" - " custom_components/hue/light.py, line 23: await session.close()" in caplog.text - ) + "Detected that custom integration 'hue' closes the Home Assistant aiohttp " + "session at custom_components/hue/light.py, line 23: await session.close(), " + "please report it to the author of the 'hue' custom integration" + ) in caplog.text async def test_async_aiohttp_proxy_stream( diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 3b9b3cf6558..a3fd02686ac 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -112,6 +112,19 @@ async def test_component_requirement_not_found(hass: HomeAssistant) -> None: assert not res.errors +async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: + """Test no errors if component not found in recovery mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} + hass.config.recovery_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + assert not res.errors + + async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: """Test no errors if component not found in safe mode.""" # Make sure they don't exist @@ -145,11 +158,11 @@ async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: assert not res.errors -async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: - """Test no errors if platform not found in safe_mode.""" +async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: + """Test no errors if platform not found in recovery_mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} - hass.config.safe_mode = True + hass.config.recovery_mode = True with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) log_ha_config(res) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 80fc1bf2241..a9ddd89a0b3 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -563,6 +563,9 @@ def test_string_with_no_html() -> None: with pytest.raises(vol.Invalid): schema("Bold") + with pytest.raises(vol.Invalid): + schema("HTML element names are case-insensitive.") + for value in ( True, 3, diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 380801123b0..1216bd6e293 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,71 +1,88 @@ """Test deprecation helpers.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import pytest +from homeassistant.core import HomeAssistant from homeassistant.helpers.deprecation import ( + deprecated_class, deprecated_function, deprecated_substitute, get_deprecated, ) +from tests.common import MockModule, mock_integration -class MockBaseClass: + +class MockBaseClassDeprecatedProperty: """Mock base class for deprecated testing.""" @property @deprecated_substitute("old_property") def new_property(self): """Test property to fetch.""" - raise NotImplementedError() - - -class MockDeprecatedClass(MockBaseClass): - """Mock deprecated class object.""" - - @property - def old_property(self): - """Test property to fetch.""" - return True - - -class MockUpdatedClass(MockBaseClass): - """Mock updated class object.""" - - @property - def new_property(self): - """Test property to fetch.""" - return True + return "default_new" @patch("logging.getLogger") def test_deprecated_substitute_old_class(mock_get_logger) -> None: """Test deprecated class object.""" + + class MockDeprecatedClass(MockBaseClassDeprecatedProperty): + """Mock deprecated class object.""" + + @property + def old_property(self): + """Test property to fetch.""" + return "old" + mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_object = MockDeprecatedClass() - assert mock_object.new_property is True - assert mock_object.new_property is True + assert mock_object.new_property == "old" assert mock_logger.warning.called assert len(mock_logger.warning.mock_calls) == 1 +@patch("logging.getLogger") +def test_deprecated_substitute_default_class(mock_get_logger) -> None: + """Test deprecated class object.""" + + class MockDefaultClass(MockBaseClassDeprecatedProperty): + """Mock updated class object.""" + + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + mock_object = MockDefaultClass() + assert mock_object.new_property == "default_new" + assert not mock_logger.warning.called + + @patch("logging.getLogger") def test_deprecated_substitute_new_class(mock_get_logger) -> None: """Test deprecated class object.""" + + class MockUpdatedClass(MockBaseClassDeprecatedProperty): + """Mock updated class object.""" + + @property + def new_property(self): + """Test property to fetch.""" + return "new" + mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_object = MockUpdatedClass() - assert mock_object.new_property is True - assert mock_object.new_property is True + assert mock_object.new_property == "new" assert not mock_logger.warning.called @patch("logging.getLogger") def test_config_get_deprecated_old(mock_get_logger) -> None: - """Test deprecated class object.""" + """Test deprecated config.""" mock_logger = MagicMock() mock_get_logger.return_value = mock_logger @@ -77,7 +94,7 @@ def test_config_get_deprecated_old(mock_get_logger) -> None: @patch("logging.getLogger") def test_config_get_deprecated_new(mock_get_logger) -> None: - """Test deprecated class object.""" + """Test deprecated config.""" mock_logger = MagicMock() mock_get_logger.return_value = mock_logger @@ -86,8 +103,27 @@ def test_config_get_deprecated_new(mock_get_logger) -> None: assert not mock_logger.warning.called +@deprecated_class("homeassistant.blah.NewClass") +class MockDeprecatedClass: + """Mock class for deprecated testing.""" + + +@patch("logging.getLogger") +def test_deprecated_class(mock_get_logger) -> None: + """Test deprecated class.""" + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + MockDeprecatedClass() + assert mock_logger.warning.called + assert len(mock_logger.warning.mock_calls) == 1 + + def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated_function decorator.""" + """Test deprecated_function decorator. + + This tests the behavior when the calling integration is not known. + """ @deprecated_function("new_function") def mock_deprecated_function(): @@ -98,3 +134,85 @@ def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: "mock_deprecated_function is a deprecated function. Use new_function instead" in caplog.text ) + + +def test_deprecated_function_called_from_built_in_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test deprecated_function decorator. + + This tests the behavior when the calling integration is built-in. + """ + + @deprecated_function("new_function") + def mock_deprecated_function(): + pass + + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + mock_deprecated_function() + assert ( + "mock_deprecated_function was called from hue, this is a deprecated function. " + "Use new_function instead" in caplog.text + ) + + +def test_deprecated_function_called_from_custom_integration( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test deprecated_function decorator. + + This tests the behavior when the calling integration is custom. + """ + + mock_integration(hass, MockModule("hue"), built_in=False) + + @deprecated_function("new_function") + def mock_deprecated_function(): + pass + + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/config/custom_components/hue/light.py", + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + mock_deprecated_function() + assert ( + "mock_deprecated_function was called from hue, this is a deprecated function. " + "Use new_function instead, please report it to the author of the 'hue' custom " + "integration" in caplog.text + ) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 380574c04fa..89f4eb5e319 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -198,11 +198,12 @@ async def test_loading_from_storage( "entry_type": dr.DeviceEntryType.SERVICE, "hw_version": "hw_version", "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name_by_user": "Test Friendly Name", "name": "name", + "serial_number": "serial_no", "sw_version": "version", "via_device_id": None, } @@ -212,7 +213,7 @@ async def test_loading_from_storage( "config_entries": [mock_config_entry.entry_id], "connections": [["Zigbee", "23.45.67.89.01"]], "id": "bcdefghijklmn", - "identifiers": [["serial", "34:56:AB:CD:EF:12"]], + "identifiers": [["serial", "3456ABCDEF12"]], "orphaned_timestamp": None, } ], @@ -227,7 +228,7 @@ async def test_loading_from_storage( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, manufacturer="manufacturer", model="model", ) @@ -240,11 +241,12 @@ async def test_loading_from_storage( entry_type=dr.DeviceEntryType.SERVICE, hw_version="hw_version", id="abcdefghijklm", - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, manufacturer="manufacturer", model="model", name_by_user="Test Friendly Name", name="name", + serial_number="serial_no", suggested_area=None, # Not stored sw_version="version", ) @@ -256,7 +258,7 @@ async def test_loading_from_storage( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "23.45.67.89.01")}, - identifiers={("serial", "34:56:AB:CD:EF:12")}, + identifiers={("serial", "3456ABCDEF12")}, manufacturer="manufacturer", model="model", ) @@ -264,7 +266,7 @@ async def test_loading_from_storage( config_entries={mock_config_entry.entry_id}, connections={("Zigbee", "23.45.67.89.01")}, id="bcdefghijklmn", - identifiers={("serial", "34:56:AB:CD:EF:12")}, + identifiers={("serial", "3456ABCDEF12")}, manufacturer="manufacturer", model="model", ) @@ -275,12 +277,12 @@ async def test_loading_from_storage( @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_1_to_1_3( +async def test_migration_1_1_to_1_4( hass: HomeAssistant, hass_storage: dict[str, Any], mock_config_entry: MockConfigEntry, ) -> None: - """Test migration from version 1.1 to 1.3.""" + """Test migration from version 1.1 to 1.4.""" hass_storage[dr.STORAGE_KEY] = { "version": 1, "minor_version": 1, @@ -291,7 +293,7 @@ async def test_migration_1_1_to_1_3( "connections": [["Zigbee", "01.23.45.67.89"]], "entry_type": "service", "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", @@ -316,7 +318,7 @@ async def test_migration_1_1_to_1_3( "connections": [], "entry_type": "service", "id": "deletedid", - "identifiers": [["serial", "12:34:56:AB:CD:FF"]], + "identifiers": [["serial", "123456ABCDFF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", @@ -333,7 +335,7 @@ async def test_migration_1_1_to_1_3( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, ) assert entry.id == "abcdefghijklm" @@ -341,7 +343,7 @@ async def test_migration_1_1_to_1_3( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, sw_version="new_version", ) assert entry.id == "abcdefghijklm" @@ -363,11 +365,12 @@ async def test_migration_1_1_to_1_3( "entry_type": "service", "hw_version": None, "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", "name_by_user": None, + "serial_number": None, "sw_version": "new_version", "via_device_id": None, }, @@ -385,6 +388,7 @@ async def test_migration_1_1_to_1_3( "model": None, "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, }, @@ -394,7 +398,7 @@ async def test_migration_1_1_to_1_3( "config_entries": ["123456"], "connections": [], "id": "deletedid", - "identifiers": [["serial", "12:34:56:AB:CD:FF"]], + "identifiers": [["serial", "123456ABCDFF"]], "orphaned_timestamp": None, } ], @@ -403,7 +407,7 @@ async def test_migration_1_1_to_1_3( @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_2_to_1_3( +async def test_migration_1_2_to_1_4( hass: HomeAssistant, hass_storage: dict[str, Any], mock_config_entry: MockConfigEntry, @@ -423,7 +427,7 @@ async def test_migration_1_2_to_1_3( "disabled_by": None, "entry_type": "service", "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", @@ -459,7 +463,7 @@ async def test_migration_1_2_to_1_3( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, ) assert entry.id == "abcdefghijklm" @@ -467,7 +471,7 @@ async def test_migration_1_2_to_1_3( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, sw_version="new_version", ) assert entry.id == "abcdefghijklm" @@ -490,11 +494,12 @@ async def test_migration_1_2_to_1_3( "entry_type": "service", "hw_version": None, "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", "name_by_user": None, + "serial_number": None, "sw_version": "new_version", "via_device_id": None, }, @@ -512,6 +517,130 @@ async def test_migration_1_2_to_1_3( "model": None, "name_by_user": None, "name": None, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_1_3_to_1_4( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +): + """Test migration from version 1.3 to 1.4.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 3, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "configuration_url": None, + "connections": [["Zigbee", "01.23.45.67.89"]], + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "name_by_user": None, + "sw_version": "version", + "via_device_id": None, + }, + { + "area_id": None, + "config_entries": [None], + "configuration_url": None, + "connections": [], + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "invalid-entry-type", + "identifiers": [["serial", "mock-id-invalid-entry"]], + "manufacturer": None, + "model": None, + "name_by_user": None, + "name": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Test data was loaded + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={("Zigbee", "01.23.45.67.89")}, + identifiers={("serial", "123456ABCDEF")}, + ) + assert entry.id == "abcdefghijklm" + + # Update to trigger a store + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={("Zigbee", "01.23.45.67.89")}, + identifiers={("serial", "123456ABCDEF")}, + sw_version="new_version", + ) + assert entry.id == "abcdefghijklm" + + # Check we store migrated data + await flush_store(registry._store) + + assert hass_storage[dr.STORAGE_KEY] == { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "configuration_url": None, + "connections": [["Zigbee", "01.23.45.67.89"]], + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "name_by_user": None, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + { + "area_id": None, + "config_entries": [None], + "configuration_url": None, + "connections": [], + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "invalid-entry-type", + "identifiers": [["serial", "mock-id-invalid-entry"]], + "manufacturer": None, + "model": None, + "name_by_user": None, + "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, }, @@ -996,6 +1125,7 @@ async def test_update( name_by_user="Test Friendly Name", name="name", new_identifiers=new_identifiers, + serial_number="serial_no", suggested_area="suggested_area", sw_version="version", via_device_id="98765B", @@ -1017,6 +1147,7 @@ async def test_update( model="Test Model", name_by_user="Test Friendly Name", name="name", + serial_number="serial_no", suggested_area="suggested_area", sw_version="version", via_device_id="98765B", @@ -1060,6 +1191,7 @@ async def test_update( "model": None, "name": None, "name_by_user": None, + "serial_number": None, "suggested_area": None, "sw_version": None, "via_device_id": None, @@ -1856,11 +1988,12 @@ async def test_loading_invalid_configuration_url_from_storage( "entry_type": dr.DeviceEntryType.SERVICE, "hw_version": None, "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": None, "model": None, "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, } @@ -1874,6 +2007,6 @@ async def test_loading_invalid_configuration_url_from_storage( assert len(registry.devices) == 1 entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, ) assert entry.configuration_url == "invalid" diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 61ee38a66a7..cf76083fe7a 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -776,9 +776,10 @@ async def test_warn_slow_write_state_custom_component( mock_entity.async_write_ha_state() assert ( - "Updating state for comp_test.test_entity " - "(.CustomComponentEntity'>) " - "took 10.000 seconds. Please report it to the custom integration author" + "Updating state for comp_test.test_entity (.CustomComponentEntity'>)" + " took 10.000 seconds. Please report it to the author of the 'hue' custom " + "integration" ) in caplog.text @@ -955,17 +956,11 @@ async def test_entity_description_fallback() -> None: async def _test_friendly_name( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, ent: entity.Entity, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name.""" - expected_warning = ( - f"Entity {ent.entity_id} ({type(ent)}) is implicitly using device name" - ) - async def async_setup_entry(hass, config_entry, async_add_entities): """Mock setup entry method.""" async_add_entities([ent]) @@ -984,7 +979,6 @@ async def _test_friendly_name( assert len(hass.states.async_entity_ids()) == 1 state = hass.states.async_all()[0] assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name - assert (expected_warning in caplog.text) is warn_implicit_name await async_update_entity(hass, ent.entity_id) assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name @@ -996,25 +990,22 @@ async def _test_friendly_name( "entity_name", "device_name", "expected_friendly_name", - "warn_implicit_name", ), ( - (False, "Entity Blu", "Device Bla", "Entity Blu", False), - (False, None, "Device Bla", None, False), - (True, "Entity Blu", "Device Bla", "Device Bla Entity Blu", False), - (True, None, "Device Bla", "Device Bla", False), - (True, "Entity Blu", UNDEFINED, "Entity Blu", False), - (True, "Entity Blu", None, "Mock Title Entity Blu", False), + (False, "Entity Blu", "Device Bla", "Entity Blu"), + (False, None, "Device Bla", None), + (True, "Entity Blu", "Device Bla", "Device Bla Entity Blu"), + (True, None, "Device Bla", "Device Bla"), + (True, "Entity Blu", UNDEFINED, "Entity Blu"), + (True, "Entity Blu", None, "Mock Title Entity Blu"), ), ) async def test_friendly_name_attr( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, device_name: str | None | UndefinedType, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity uses _attr_*.""" @@ -1030,31 +1021,27 @@ async def test_friendly_name_attr( ent._attr_name = entity_name await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "entity_name", "expected_friendly_name"), ( - (False, "Entity Blu", "Entity Blu", False), - (False, None, None, False), - (False, UNDEFINED, None, False), - (True, "Entity Blu", "Device Bla Entity Blu", False), - (True, None, "Device Bla", False), - (True, UNDEFINED, "Device Bla", True), + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (False, UNDEFINED, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), + (True, UNDEFINED, "Device Bla None"), ), ) async def test_friendly_name_description( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has an entity description.""" @@ -1071,31 +1058,27 @@ async def test_friendly_name_description( ) await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "entity_name", "expected_friendly_name"), ( - (False, "Entity Blu", "Entity Blu", False), - (False, None, None, False), - (False, UNDEFINED, None, False), - (True, "Entity Blu", "Device Bla Entity Blu", False), - (True, None, "Device Bla", False), - (True, UNDEFINED, "Device Bla English cls", False), + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (False, UNDEFINED, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), + (True, UNDEFINED, "Device Bla English cls"), ), ) async def test_friendly_name_description_device_class_name( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has an entity description.""" @@ -1138,31 +1121,27 @@ async def test_friendly_name_description_device_class_name( ): await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "entity_name", "expected_friendly_name"), ( - (False, "Entity Blu", "Entity Blu", False), - (False, None, None, False), - (False, UNDEFINED, None, False), - (True, "Entity Blu", "Device Bla Entity Blu", False), - (True, None, "Device Bla", False), - (True, UNDEFINED, "Device Bla", True), + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (False, UNDEFINED, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), + (True, UNDEFINED, "Device Bla None"), ), ) async def test_friendly_name_property( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has overridden the name property.""" @@ -1178,32 +1157,28 @@ async def test_friendly_name_property( ) await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "entity_name", "expected_friendly_name"), ( - (False, "Entity Blu", "Entity Blu", False), - (False, None, None, False), - (False, UNDEFINED, None, False), - (True, "Entity Blu", "Device Bla Entity Blu", False), - (True, None, "Device Bla", False), + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (False, UNDEFINED, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), # Won't use the device class name because the entity overrides the name property - (True, UNDEFINED, "Device Bla None", False), + (True, UNDEFINED, "Device Bla None"), ), ) async def test_friendly_name_property_device_class_name( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has overridden the name property.""" @@ -1243,26 +1218,22 @@ async def test_friendly_name_property_device_class_name( ): await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "expected_friendly_name"), ( - (False, None, False), - (True, "Device Bla English cls", False), + (False, None), + (True, "Device Bla English cls"), ), ) async def test_friendly_name_device_class_name( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has not set the name in any way.""" @@ -1301,10 +1272,8 @@ async def test_friendly_name_device_class_name( ): await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @@ -1499,7 +1468,7 @@ async def test_invalid_state( assert ( "homeassistant.helpers.entity", logging.ERROR, - f"Failed to set state, fall back to {STATE_UNKNOWN}", + f"Failed to set state for test.test, fall back to {STATE_UNKNOWN}", ) in caplog.record_tuples ent._attr_state = "x" * 255 @@ -1550,7 +1519,7 @@ async def test_suggest_report_issue_custom_component( mock_integration( hass, MockModule( - domain="test", partial_manifest={"issue_tracker": "httpts://some_url"} + domain="test", partial_manifest={"issue_tracker": "https://some_url"} ), built_in=False, ) @@ -1558,4 +1527,4 @@ async def test_suggest_report_issue_custom_component( await platform.async_add_entities([mock_entity]) suggestion = mock_entity._suggest_report_issue() - assert suggestion == "create a bug report at httpts://some_url" + assert suggestion == "create a bug report at https://some_url" diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index f62addb9a64..95558e9c73d 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -665,6 +665,7 @@ async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> No ) assert updated_entry != entry assert updated_entry.unique_id == new_unique_id + assert updated_entry.previous_unique_id == "5678" assert mock_schedule_save.call_count == 1 assert entity_registry.async_get_entity_id("light", "hue", "5678") is None @@ -1684,3 +1685,69 @@ async def test_restore_entity(hass, update_events, freezer): assert update_events[11] == {"action": "remove", "entity_id": "light.hue_1234"} # Restore entities the 3rd time assert update_events[12] == {"action": "create", "entity_id": "light.hue_1234"} + + +async def test_async_migrate_entry_delete_self(hass): + """Test async_migrate_entry.""" + registry = er.async_get(hass) + config_entry1 = MockConfigEntry(domain="test1") + config_entry2 = MockConfigEntry(domain="test2") + entry1 = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" + ) + entry2 = registry.async_get_or_create( + "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" + ) + entry3 = registry.async_get_or_create( + "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" + ) + + @callback + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + entries.add(entity_entry.entity_id) + if entity_entry == entry1: + registry.async_remove(entry1.entity_id) + return None + if entity_entry == entry2: + return {"original_name": "Entry 2 renamed"} + return None + + entries = set() + await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) + assert entries == {entry1.entity_id, entry2.entity_id} + assert not registry.async_is_registered(entry1.entity_id) + entry2 = registry.async_get(entry2.entity_id) + assert entry2.original_name == "Entry 2 renamed" + assert registry.async_get(entry3.entity_id) is entry3 + + +async def test_async_migrate_entry_delete_other(hass): + """Test async_migrate_entry.""" + registry = er.async_get(hass) + config_entry1 = MockConfigEntry(domain="test1") + config_entry2 = MockConfigEntry(domain="test2") + entry1 = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" + ) + entry2 = registry.async_get_or_create( + "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" + ) + registry.async_get_or_create( + "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" + ) + + @callback + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + entries.add(entity_entry.entity_id) + if entity_entry == entry1: + registry.async_remove(entry2.entity_id) + return None + if entity_entry == entry2: + # We should not get here + pytest.fail() + return None + + entries = set() + await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) + assert entries == {entry1.entity_id} + assert not registry.async_is_registered(entry2.entity_id) diff --git a/tests/helpers/test_entity_values.py b/tests/helpers/test_entity_values.py index 1ac8e480f51..f32db73a788 100644 --- a/tests/helpers/test_entity_values.py +++ b/tests/helpers/test_entity_values.py @@ -9,6 +9,7 @@ ent = "test.test" def test_override_single_value() -> None: """Test values with exact match.""" store = EV({ent: {"key": "value"}}) + store.get.cache_clear() assert store.get(ent) == {"key": "value"} assert store.get.cache_info().currsize == 1 assert store.get.cache_info().misses == 1 diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 3086bebe09d..f1547f36e39 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,21 +1,70 @@ """Test the frame helper.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import ANY, Mock, patch import pytest +from homeassistant.core import HomeAssistant from homeassistant.helpers import frame +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + async def test_extract_frame_integration( caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test extracting the current frame from integration context.""" - found_frame, integration, path = frame.get_integration_frame() + integration_frame = frame.get_integration_frame() + assert integration_frame == frame.IntegrationFrame( + custom_integration=False, + frame=mock_integration_frame, + integration="hue", + module=None, + relative_filename="homeassistant/components/hue/light.py", + ) - assert integration == "hue" - assert path == "homeassistant/components/" - assert found_frame == mock_integration_frame + +async def test_extract_frame_resolve_module( + hass: HomeAssistant, enable_custom_integrations +) -> None: + """Test extracting the current frame from integration context.""" + from custom_components.test_integration_frame import call_get_integration_frame + + integration_frame = call_get_integration_frame() + + assert integration_frame == frame.IntegrationFrame( + custom_integration=True, + frame=ANY, + integration="test_integration_frame", + module="custom_components.test_integration_frame", + relative_filename="custom_components/test_integration_frame/__init__.py", + ) async def test_extract_frame_integration_with_excluded_integration( @@ -48,13 +97,17 @@ async def test_extract_frame_integration_with_excluded_integration( ), ], ): - found_frame, integration, path = frame.get_integration_frame( + integration_frame = frame.get_integration_frame( exclude_integrations={"zeroconf"} ) - assert integration == "mdns" - assert path == "homeassistant/components/" - assert found_frame == correct_frame + assert integration_frame == frame.IntegrationFrame( + custom_integration=False, + frame=correct_frame, + integration="mdns", + module=None, + relative_filename="homeassistant/components/mdns/light.py", + ) async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> None: @@ -77,23 +130,33 @@ async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> frame.get_integration_frame() -@pytest.mark.usefixtures("mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_prevent_flooding(caplog: pytest.LogCaptureFixture) -> None: +async def test_prevent_flooding( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock +) -> None: """Test to ensure a report is only written once to the log.""" what = "accessed hi instead of hello" key = "/home/paulus/homeassistant/components/hue/light.py:23" + integration = "hue" + filename = "homeassistant/components/hue/light.py" + + expected_message = ( + f"Detected that integration '{integration}' {what} at {filename}, line " + f"{mock_integration_frame.lineno}: {mock_integration_frame.line}, " + f"please create a bug report at https://github.com/home-assistant/core/issues?" + f"q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+{integration}%22" + ) frame.report(what, error_if_core=False) - assert what in caplog.text + assert expected_message in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 caplog.clear() frame.report(what, error_if_core=False) - assert what not in caplog.text + assert expected_message not in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index f9473ffaf87..693c45cc73a 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -7,6 +7,8 @@ import pytest from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE, HomeAssistant import homeassistant.helpers.httpx_client as client +from tests.common import MockModule, mock_integration + async def test_get_async_client_with_ssl(hass: HomeAssistant) -> None: """Test init async client with ssl.""" @@ -125,9 +127,10 @@ async def test_warning_close_session_integration( await httpx_session.aclose() assert ( - "Detected integration that closes the Home Assistant httpx client. " - "Please report issue for hue using this method at " - "homeassistant/components/hue/light.py, line 23: await session.aclose()" + "Detected that integration 'hue' closes the Home Assistant httpx client at " + "homeassistant/components/hue/light.py, line 23: await session.aclose(), " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text @@ -136,6 +139,7 @@ async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test log warning message when closing the session from custom context.""" + mock_integration(hass, MockModule("hue"), built_in=False) with patch( "homeassistant.helpers.frame.extract_stack", return_value=[ @@ -159,8 +163,7 @@ async def test_warning_close_session_custom( httpx_session = client.get_async_client(hass) await httpx_session.aclose() assert ( - "Detected integration that closes the Home Assistant httpx client. Please" - " report issue to the custom integration author for hue using this method at" - " custom_components/hue/light.py, line 23: await session.aclose()" - in caplog.text - ) + "Detected that custom integration 'hue' closes the Home Assistant httpx client " + "at custom_components/hue/light.py, line 23: await session.aclose(), " + "please report it to the author of the 'hue' custom integration" + ) in caplog.text diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 0bc8e0f1ff3..7954b63b241 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -671,3 +671,80 @@ async def test_options_flow_state(hass: HomeAssistant) -> None: "idx_from_flow_state": "blublu", "option1": "blabla", } + + +async def test_options_flow_omit_optional_keys( + hass: HomeAssistant, manager: data_entry_flow.FlowManager +) -> None: + """Test handling of advanced options in options flow.""" + manager.hass = hass + + OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional("optional_no_default"): str, + vol.Optional("optional_default", default="a very reasonable default"): str, + vol.Optional("advanced_no_default", description={"advanced": True}): str, + vol.Optional( + "advanced_default", + default="a very reasonable default", + description={"advanced": True}, + ): str, + } + ) + + OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "init": SchemaFlowFormStep(OPTIONS_SCHEMA) + } + + class TestFlow(MockSchemaConfigFlowHandler, domain="test"): + config_flow = {} + options_flow = OPTIONS_FLOW + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + config_entry = MockConfigEntry( + data={}, + domain="test", + options={ + "optional_no_default": "abc123", + "optional_default": "not default", + "advanced_no_default": "abc123", + "advanced_default": "not default", + }, + ) + config_entry.add_to_hass(hass) + + # Start flow in basic mode + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert list(result["data_schema"].schema.keys()) == [ + "optional_no_default", + "optional_default", + ] + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + "advanced_default": "not default", + "advanced_no_default": "abc123", + "optional_default": "a very reasonable default", + } + + # Start flow in advanced mode + 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 list(result["data_schema"].schema.keys()) == [ + "optional_no_default", + "optional_default", + "advanced_no_default", + "advanced_default", + ] + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + "advanced_default": "a very reasonable default", + "optional_default": "a very reasonable default", + } diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 8e4409daa54..6c327345881 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -13,7 +13,7 @@ import pytest import voluptuous as vol # Otherwise can't test just this file (import order issue) -from homeassistant import exceptions +from homeassistant import config_entries, exceptions import homeassistant.components.scene as scene from homeassistant.const import ( ATTR_ENTITY_ID, @@ -33,6 +33,7 @@ from homeassistant.core import ( from homeassistant.exceptions import ConditionError, HomeAssistantError, ServiceNotFound from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, entity_registry as er, script, template, @@ -43,6 +44,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, async_capture_events, async_fire_time_changed, async_mock_service, @@ -4532,12 +4534,23 @@ async def test_set_redefines_variable( assert_action_trace(expected_trace) -async def test_validate_action_config(hass: HomeAssistant) -> None: +async def test_validate_action_config( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Validate action config.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + mock_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")}, + ) + def templated_device_action(message): return { - "device_id": "abcd", + "device_id": mock_device.id, "domain": "mobile_app", "message": f"{message} {{{{ 5 + 5}}}}", "type": "notify", diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 590526cdb2b..ee4749be346 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -479,6 +479,26 @@ def test_config_entry_selector_schema( _test_selector("config_entry", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {}, + ("NL", "DE"), + (None, True, 1), + ), + ( + {"countries": ["NL", "DE"]}, + ("NL", "DE"), + (None, True, 1, "sv", "en"), + ), + ), +) +def test_country_selector_schema(schema, valid_selections, invalid_selections) -> None: + """Test country selector.""" + _test_selector("country", schema, valid_selections, invalid_selections) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), (({}, ("00:00:00",), ("blah", None)),), diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 58e0c730165..c466bfed213 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -7,6 +7,7 @@ import json import logging import math import random +from types import MappingProxyType from typing import Any from unittest.mock import patch @@ -43,6 +44,7 @@ from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import TemplateVarsType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import UnitSystem from tests.common import MockConfigEntry, async_fire_time_changed @@ -475,6 +477,171 @@ def test_isnumber(hass: HomeAssistant, value, expected) -> None: ) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], True), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_list(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test is list.""" + assert ( + template.Template("{{ value is list }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, True), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test is set.""" + assert ( + template.Template("{{ value is set }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), True), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test is tuple.""" + assert ( + template.Template("{{ value is tuple }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], {1, 2}), + ({1, 2}, {1, 2}), + ({"a": 1, "b": 2}, {"a", "b"}), + (ReadOnlyDict({"a": 1, "b": 2}), {"a", "b"}), + (MappingProxyType({"a": 1, "b": 2}), {"a", "b"}), + ("abc", {"a", "b", "c"}), + (b"abc", {97, 98, 99}), + ((1, 2), {1, 2}), + ], +) +def test_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test convert to set function.""" + assert ( + template.Template("{{ set(value) }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], (1, 2)), + ({1, 2}, (1, 2)), + ({"a": 1, "b": 2}, ("a", "b")), + (ReadOnlyDict({"a": 1, "b": 2}), ("a", "b")), + (MappingProxyType({"a": 1, "b": 2}), ("a", "b")), + ("abc", ("a", "b", "c")), + (b"abc", (97, 98, 99)), + ((1, 2), (1, 2)), + ], +) +def test_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test convert to tuple function.""" + assert ( + template.Template("{{ tuple(value) }}", hass).async_render({"value": value}) + == expected + ) + + +def test_converting_datetime_to_iterable(hass: HomeAssistant) -> None: + """Test converting a datetime to an iterable raises an error.""" + dt_ = datetime(2020, 1, 1, 0, 0, 0) + with pytest.raises(TemplateError): + template.Template("{{ tuple(value) }}", hass).async_render({"value": dt_}) + with pytest.raises(TemplateError): + template.Template("{{ set(value) }}", hass).async_render({"value": dt_}) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), True), + ], +) +def test_is_datetime(hass: HomeAssistant, value, expected) -> None: + """Test is datetime.""" + assert ( + template.Template("{{ value is datetime }}", hass).async_render( + {"value": value} + ) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", True), + (b"abc", True), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_string_like(hass: HomeAssistant, value, expected) -> None: + """Test is string_like.""" + assert ( + template.Template("{{ value is string_like }}", hass).async_render( + {"value": value} + ) + == expected + ) + + def test_rounding_value(hass: HomeAssistant) -> None: """Test rounding value.""" hass.states.async_set("sensor.temperature", 12.78) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index ea9e04ac993..555bcbdf6b2 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -103,7 +103,7 @@ async def test_empty_setup(hass: HomeAssistant) -> None: assert domain in hass.config.components, domain -async def test_core_failure_loads_safe_mode( +async def test_core_failure_loads_recovery_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test failing core setup aborts further setup.""" @@ -488,14 +488,14 @@ async def test_setup_hass( log_file=log_file, log_no_color=log_no_color, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) assert "Waiting on integrations to complete setup" not in caplog.text assert "browser" in hass.config.components - assert "safe_mode" not in hass.config.components + assert "recovery_mode" not in hass.config.components assert len(mock_enable_logging.mock_calls) == 1 assert mock_enable_logging.mock_calls[0][1] == ( @@ -547,7 +547,7 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( log_file=log_file, log_no_color=log_no_color, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) @@ -574,11 +574,11 @@ async def test_setup_hass_invalid_yaml( log_file="", log_no_color=False, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) - assert "safe_mode" in hass.config.components + assert "recovery_mode" in hass.config.components assert len(mock_mount_local_lib_path.mock_calls) == 0 @@ -602,14 +602,14 @@ async def test_setup_hass_config_dir_nonexistent( log_file="", log_no_color=False, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) is None ) -async def test_setup_hass_safe_mode( +async def test_setup_hass_recovery_mode( mock_enable_logging: Mock, mock_is_virtual_env: Mock, mock_mount_local_lib_path: AsyncMock, @@ -630,11 +630,11 @@ async def test_setup_hass_safe_mode( log_file="", log_no_color=False, skip_pip=True, - safe_mode=True, + recovery_mode=True, ), ) - assert "safe_mode" in hass.config.components + assert "recovery_mode" in hass.config.components assert len(mock_mount_local_lib_path.mock_calls) == 0 # Validate we didn't try to set up config entry. @@ -642,6 +642,72 @@ async def test_setup_hass_safe_mode( assert len(browser_setup.mock_calls) == 0 +async def test_setup_hass_safe_mode( + mock_hass_config: None, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + caplog: pytest.LogCaptureFixture, + event_loop: asyncio.AbstractEventLoop, +) -> None: + """Test it works.""" + with patch("homeassistant.components.browser.setup"), patch( + "homeassistant.config_entries.ConfigEntries.async_domains", + return_value=["browser"], + ): + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=False, + safe_mode=True, + ), + ) + + assert "recovery_mode" not in hass.config.components + assert "Starting in recovery mode" not in caplog.text + assert "Starting in safe mode" in caplog.text + + +async def test_setup_hass_recovery_mode_and_safe_mode( + mock_hass_config: None, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + caplog: pytest.LogCaptureFixture, + event_loop: asyncio.AbstractEventLoop, +) -> None: + """Test it works.""" + with patch("homeassistant.components.browser.setup"), patch( + "homeassistant.config_entries.ConfigEntries.async_domains", + return_value=["browser"], + ): + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=True, + safe_mode=True, + ), + ) + + assert "recovery_mode" in hass.config.components + assert "Starting in recovery mode" in caplog.text + assert "Starting in safe mode" not in caplog.text + + @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) async def test_setup_hass_invalid_core_config( mock_hass_config: None, @@ -661,11 +727,11 @@ async def test_setup_hass_invalid_core_config( log_file="", log_no_color=False, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) - assert "safe_mode" in hass.config.components + assert "recovery_mode" in hass.config.components @pytest.mark.parametrize( @@ -681,7 +747,7 @@ async def test_setup_hass_invalid_core_config( } ], ) -async def test_setup_safe_mode_if_no_frontend( +async def test_setup_recovery_mode_if_no_frontend( mock_hass_config: None, mock_enable_logging: Mock, mock_is_virtual_env: Mock, @@ -690,7 +756,7 @@ async def test_setup_safe_mode_if_no_frontend( mock_process_ha_config_upgrade: Mock, event_loop: asyncio.AbstractEventLoop, ) -> None: - """Test we setup safe mode if frontend didn't load.""" + """Test we setup recovery mode if frontend didn't load.""" verbose = Mock() log_rotate_days = Mock() log_file = Mock() @@ -704,11 +770,11 @@ async def test_setup_safe_mode_if_no_frontend( log_file=log_file, log_no_color=log_no_color, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) - assert "safe_mode" in hass.config.components + assert "recovery_mode" in hass.config.components assert hass.config.config_dir == get_test_config_dir() assert hass.config.skip_pip assert hass.config.internal_url == "http://192.168.1.100:8123" diff --git a/tests/test_config.py b/tests/test_config.py index aeb25313302..d5181bbe115 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,6 +49,7 @@ VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, config_util.AUTOMATION_CONFIG_PATH) SCRIPTS_PATH = os.path.join(CONFIG_DIR, config_util.SCRIPT_CONFIG_PATH) SCENES_PATH = os.path.join(CONFIG_DIR, config_util.SCENE_CONFIG_PATH) +SAFE_MODE_PATH = os.path.join(CONFIG_DIR, config_util.SAFE_MODE_FILENAME) def create_file(path): @@ -80,6 +81,9 @@ def teardown(): if os.path.isfile(SCENES_PATH): os.remove(SCENES_PATH) + if os.path.isfile(SAFE_MODE_PATH): + os.remove(SAFE_MODE_PATH) + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" @@ -1386,3 +1390,12 @@ async def test_core_store_no_country( await hass.config.async_update(**{"country": "SE"}) issue = issue_registry.async_get_issue("homeassistant", issue_id) assert not issue + + +async def test_safe_mode(hass: HomeAssistant) -> None: + """Test safe mode.""" + assert config_util.safe_mode_enabled(hass.config.config_dir) is False + assert config_util.safe_mode_enabled(hass.config.config_dir) is False + await config_util.async_enable_safe_mode(hass) + assert config_util.safe_mode_enabled(hass.config.config_dir) is True + assert config_util.safe_mode_enabled(hass.config.config_dir) is False diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 52caa1ae275..eb771b7e6a6 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2171,6 +2171,9 @@ async def test_manual_add_overrides_ignored_entry( ) return self.async_show_form(step_id="step2") + async def async_step_step2(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload: @@ -2500,6 +2503,9 @@ async def test_partial_flows_hidden( await pause_discovery.wait() return self.async_show_form(step_id="someform") + async def async_step_someform(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): # Start a config entry flow and wait for it to be blocked init_task = asyncio.ensure_future( @@ -2788,6 +2794,9 @@ async def test_flow_with_default_discovery_with_unique_id( await self._async_handle_discovery_without_unique_id() return self.async_show_form(step_id="mock") + async def async_step_mock(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DISCOVERY} @@ -2841,6 +2850,9 @@ async def test_default_discovery_in_progress( await self._async_handle_discovery_without_unique_id() return self.async_show_form(step_id="mock") + async def async_step_mock(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): result = await manager.flow.async_init( "comp", @@ -2878,6 +2890,9 @@ async def test_default_discovery_abort_on_new_unique_flow( await self._async_handle_discovery_without_unique_id() return self.async_show_form(step_id="mock") + async def async_step_mock(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): # First discovery with default, no unique ID result2 = await manager.flow.async_init( @@ -2922,6 +2937,9 @@ async def test_default_discovery_abort_on_user_flow_complete( await self._async_handle_discovery_without_unique_id() return self.async_show_form(step_id="mock") + async def async_step_mock(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): # First discovery with default, no unique ID flow1 = await manager.flow.async_init( @@ -3773,6 +3791,20 @@ async def test_reauth(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 2 + # Abort all existing flows + for flow in hass.config_entries.flow.async_progress(): + hass.config_entries.flow.async_abort(flow["flow_id"]) + await hass.async_block_till_done() + + # Check that we can't start duplicate reauth flows + # without blocking between flows + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 + async def test_get_active_flows(hass: HomeAssistant) -> None: """Test the async_get_active_flows helper.""" @@ -3968,6 +4000,9 @@ async def test_preview_supported( """Mock Reauth.""" return self.async_show_form(step_id="next", preview="test") + async def async_step_next(self, user_input=None): + raise NotImplementedError + @staticmethod async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview.""" @@ -4006,6 +4041,9 @@ async def test_preview_not_supported( """Mock Reauth.""" return self.async_show_form(step_id="user_confirm") + async def async_step_user_confirm(self, user_input=None): + raise NotImplementedError + mock_integration(hass, MockModule("test")) mock_entity_platform(hass, "config_flow.test", None) diff --git a/tests/test_core.py b/tests/test_core.py index 7cafadb638c..957da634dce 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -43,6 +43,7 @@ from homeassistant.core import ( State, SupportsResponse, callback, + get_release_channel, ) from homeassistant.exceptions import ( HomeAssistantError, @@ -1448,7 +1449,7 @@ async def test_config_defaults() -> None: assert config.allowlist_external_dirs == set() assert config.allowlist_external_urls == set() assert config.media_dirs == {} - assert config.safe_mode is False + assert config.recovery_mode is False assert config.legacy_templates is False assert config.currency == "EUR" assert config.country is None @@ -1486,13 +1487,14 @@ async def test_config_as_dict() -> None: "allowlist_external_urls": set(), "version": __version__, "config_source": ha.ConfigSource.DEFAULT, - "safe_mode": False, + "recovery_mode": False, "state": "RUNNING", "external_url": None, "internal_url": None, "currency": "EUR", "country": None, "language": "en", + "safe_mode": False, } assert expected == config.as_dict() @@ -2481,3 +2483,18 @@ async def test_validate_state(hass: HomeAssistant) -> None: assert ha.validate_state("test") == "test" with pytest.raises(InvalidStateError): ha.validate_state("t" * 256) + + +@pytest.mark.parametrize( + ("version", "release_channel"), + [ + ("0.115.0.dev20200815", "nightly"), + ("0.115.0", "stable"), + ("0.115.0b4", "beta"), + ("0.115.0dev0", "dev"), + ], +) +async def test_get_release_channel(version: str, release_channel: str) -> None: + """Test if release channel detection works from Home Assistant version number.""" + with patch("homeassistant.core.__version__", f"{version}"): + assert get_release_channel() == release_channel diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index e6a28fc2e4f..98380890e41 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -621,6 +621,35 @@ async def test_move_to_unknown_step_raises_and_removes_from_in_progress( assert manager.async_progress() == [] +@pytest.mark.parametrize( + ("result_type", "params"), + [ + ("async_external_step_done", {"next_step_id": "does_not_exist"}), + ("async_external_step", {"step_id": "does_not_exist", "url": "blah"}), + ("async_show_form", {"step_id": "does_not_exist"}), + ("async_show_menu", {"step_id": "does_not_exist", "menu_options": []}), + ("async_show_progress_done", {"next_step_id": "does_not_exist"}), + ("async_show_progress", {"step_id": "does_not_exist", "progress_action": ""}), + ], +) +async def test_next_step_unknown_step_raises_and_removes_from_in_progress( + manager, result_type: str, params: dict[str, str] +) -> None: + """Test that moving to an unknown step raises and removes the flow from in progress.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 1 + + async def async_step_init(self, user_input=None): + return getattr(self, result_type)(**params) + + with pytest.raises(data_entry_flow.UnknownStep): + await manager.async_init("test", context={"init_step": "init"}) + + assert manager.async_progress() == [] + + async def test_configure_raises_unknown_flow_if_not_in_progress(manager) -> None: """Test configure raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): diff --git a/tests/test_loader.py b/tests/test_loader.py index b62e25b79e3..7959ddb4684 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -11,35 +11,46 @@ from homeassistant.core import HomeAssistant, callback from .common import MockModule, async_get_persistent_notifications, mock_integration -async def test_component_dependencies(hass: HomeAssistant) -> None: - """Test if we can get the proper load order of components.""" +async def test_circular_component_dependencies(hass: HomeAssistant) -> None: + """Test if we can detect circular dependencies of components.""" mock_integration(hass, MockModule("mod1")) mock_integration(hass, MockModule("mod2", ["mod1"])) - mod_3 = mock_integration(hass, MockModule("mod3", ["mod2"])) + mock_integration(hass, MockModule("mod3", ["mod1"])) + mod_4 = mock_integration(hass, MockModule("mod4", ["mod2", "mod3"])) - assert {"mod1", "mod2", "mod3"} == await loader._async_component_dependencies( - hass, "mod_3", mod_3, set(), set() - ) + deps = await loader._async_component_dependencies(hass, mod_4) + assert deps == {"mod1", "mod2", "mod3", "mod4"} - # Create circular dependency + # Create a circular dependency + mock_integration(hass, MockModule("mod1", ["mod4"])) + with pytest.raises(loader.CircularDependency): + await loader._async_component_dependencies(hass, mod_4) + + # Create a different circular dependency mock_integration(hass, MockModule("mod1", ["mod3"])) - with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, "mod_3", mod_3, set(), set()) + await loader._async_component_dependencies(hass, mod_4) - # Depend on non-existing component - mod_1 = mock_integration(hass, MockModule("mod1", ["nonexisting"])) - - with pytest.raises(loader.IntegrationNotFound): - await loader._async_component_dependencies(hass, "mod_1", mod_1, set(), set()) - - # Having an after dependency 2 deps down that is circular - mod_1 = mock_integration( - hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod_3"]}) + # Create a circular after_dependency + mock_integration( + hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) ) - with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, "mod_3", mod_3, set(), set()) + await loader._async_component_dependencies(hass, mod_4) + + # Create a different circular after_dependency + mock_integration( + hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]}) + ) + with pytest.raises(loader.CircularDependency): + await loader._async_component_dependencies(hass, mod_4) + + +async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: + """Test if we can detect nonexistent dependencies of components.""" + mod_1 = mock_integration(hass, MockModule("mod1", ["nonexistent"])) + with pytest.raises(loader.IntegrationNotFound): + await loader._async_component_dependencies(hass, mod_1) def test_component_loader(hass: HomeAssistant) -> None: @@ -679,9 +690,9 @@ async def test_get_mqtt(hass: HomeAssistant) -> None: assert mqtt["test_2"] == ["test_2/discovery"] -async def test_get_custom_components_safe_mode(hass: HomeAssistant) -> None: - """Test that we get empty custom components in safe mode.""" - hass.config.safe_mode = True +async def test_get_custom_components_recovery_mode(hass: HomeAssistant) -> None: + """Test that we get empty custom components in recovery mode.""" + hass.config.recovery_mode = True assert await loader.async_get_custom_components(hass) == {} @@ -733,3 +744,132 @@ async def test_loggers(hass: HomeAssistant) -> None: }, ) assert integration.loggers == ["name1", "name2"] + + +CORE_ISSUE_TRACKER = ( + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" +) +CORE_ISSUE_TRACKER_BUILT_IN = ( + CORE_ISSUE_TRACKER + "+label%3A%22integration%3A+bla_built_in%22" +) +CORE_ISSUE_TRACKER_CUSTOM = ( + CORE_ISSUE_TRACKER + "+label%3A%22integration%3A+bla_custom%22" +) +CORE_ISSUE_TRACKER_CUSTOM_NO_TRACKER = ( + CORE_ISSUE_TRACKER + "+label%3A%22integration%3A+bla_custom_no_tracker%22" +) +CORE_ISSUE_TRACKER_HUE = CORE_ISSUE_TRACKER + "+label%3A%22integration%3A+hue%22" +CUSTOM_ISSUE_TRACKER = "https://blablabla.com" + + +@pytest.mark.parametrize( + ("domain", "module", "issue_tracker"), + [ + # If no information is available, open issue on core + (None, None, CORE_ISSUE_TRACKER), + ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), + ("hue", None, CORE_ISSUE_TRACKER_HUE), + ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), + # Integration domain is not currently deduced from module + (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), + ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), + # Custom integration with known issue tracker + ("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), + ("bla_custom", None, CUSTOM_ISSUE_TRACKER), + # Custom integration without known issue tracker + (None, "custom_components.bla.sensor", None), + ("bla_custom_no_tracker", "custom_components.bla_custom.sensor", None), + ("bla_custom_no_tracker", None, None), + ("hue", "custom_components.bla.sensor", None), + # Integration domain has priority over module + ("bla_custom_no_tracker", "homeassistant.components.bla_custom.sensor", None), + ], +) +async def test_async_get_issue_tracker( + hass, domain: str | None, module: str | None, issue_tracker: str | None +) -> None: + """Test async_get_issue_tracker.""" + mock_integration(hass, MockModule("bla_built_in")) + mock_integration( + hass, + MockModule( + "bla_custom", partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER} + ), + built_in=False, + ) + mock_integration(hass, MockModule("bla_custom_no_tracker"), built_in=False) + assert ( + loader.async_get_issue_tracker(hass, integration_domain=domain, module=module) + == issue_tracker + ) + + +@pytest.mark.parametrize( + ("domain", "module", "issue_tracker"), + [ + # If no information is available, open issue on core + (None, None, CORE_ISSUE_TRACKER), + ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), + ("hue", None, CORE_ISSUE_TRACKER_HUE), + ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), + # Integration domain is not currently deduced from module + (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), + ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), + # Custom integration with known issue tracker - can't find it without hass + ("bla_custom", "custom_components.bla_custom.sensor", None), + # Assumed to be a core integration without hass and without module + ("bla_custom", None, CORE_ISSUE_TRACKER_CUSTOM), + ], +) +async def test_async_get_issue_tracker_no_hass( + hass, domain: str | None, module: str | None, issue_tracker: str +) -> None: + """Test async_get_issue_tracker.""" + mock_integration(hass, MockModule("bla_built_in")) + mock_integration( + hass, + MockModule( + "bla_custom", partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER} + ), + built_in=False, + ) + assert ( + loader.async_get_issue_tracker(None, integration_domain=domain, module=module) + == issue_tracker + ) + + +REPORT_CUSTOM = ( + "report it to the author of the 'bla_custom_no_tracker' custom integration" +) +REPORT_CUSTOM_UNKNOWN = "report it to the custom integration author" + + +@pytest.mark.parametrize( + ("domain", "module", "report_issue"), + [ + (None, None, f"create a bug report at {CORE_ISSUE_TRACKER}"), + ("bla_custom", None, f"create a bug report at {CUSTOM_ISSUE_TRACKER}"), + ("bla_custom_no_tracker", None, REPORT_CUSTOM), + (None, "custom_components.hue.sensor", REPORT_CUSTOM_UNKNOWN), + ], +) +async def test_async_suggest_report_issue( + hass, domain: str | None, module: str | None, report_issue: str +) -> None: + """Test async_suggest_report_issue.""" + mock_integration(hass, MockModule("bla_built_in")) + mock_integration( + hass, + MockModule( + "bla_custom", partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER} + ), + built_in=False, + ) + mock_integration(hass, MockModule("bla_custom_no_tracker"), built_in=False) + assert ( + loader.async_suggest_report_issue( + hass, integration_domain=domain, module=module + ) + == report_issue + ) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 388e8607eca..4fa10b92706 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -42,7 +42,6 @@ async def test_requirement_installed_in_venv(hass: HomeAssistant) -> None: "package==0.0.1", constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), timeout=60, - no_cache_dir=False, ) @@ -64,7 +63,6 @@ async def test_requirement_installed_in_deps(hass: HomeAssistant) -> None: target=hass.config.path("deps"), constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), timeout=60, - no_cache_dir=False, ) @@ -379,10 +377,8 @@ async def test_install_with_wheels_index(hass: HomeAssistant) -> None: assert mock_inst.call_args == call( "hello==1.0.0", - find_links="https://wheels.hass.io/test", constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), timeout=60, - no_cache_dir=True, ) @@ -406,7 +402,6 @@ async def test_install_on_docker(hass: HomeAssistant) -> None: "hello==1.0.0", constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), timeout=60, - no_cache_dir=True, ) diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index 84864c1dbb2..633a5e4c389 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -125,11 +125,6 @@ class MockWeather(MockEntity, WeatherEntity): """Return the unit of measurement for visibility.""" return self._handle("native_visibility_unit") - @property - def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" - return self._handle("forecast") - @property def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" @@ -291,34 +286,3 @@ class MockWeatherMockForecast(MockWeather): ATTR_FORECAST_HUMIDITY: self.humidity, } ] - - -class MockWeatherMockLegacyForecastOnly(MockWeather): - """Mock weather class with mocked legacy forecast.""" - - def __init__(self, **values: Any) -> None: - """Initialize.""" - super().__init__(**values) - self.forecast_list: list[Forecast] | None = [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - } - ] - - @property - def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" - return self.forecast_list diff --git a/tests/testing_config/custom_components/test_integration_frame/__init__.py b/tests/testing_config/custom_components/test_integration_frame/__init__.py new file mode 100644 index 00000000000..d342509d52e --- /dev/null +++ b/tests/testing_config/custom_components/test_integration_frame/__init__.py @@ -0,0 +1,8 @@ +"""An integration which calls helpers.frame.get_integration_frame.""" + +from homeassistant.helpers import frame + + +def call_get_integration_frame() -> frame.IntegrationFrame: + """Call get_integration_frame.""" + return frame.get_integration_frame() diff --git a/tests/testing_config/custom_components/test_integration_frame/manifest.json b/tests/testing_config/custom_components/test_integration_frame/manifest.json new file mode 100644 index 00000000000..3c3eceec28d --- /dev/null +++ b/tests/testing_config/custom_components/test_integration_frame/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "test_integration_frame", + "name": "Test Integration Frame", + "documentation": "http://example.com", + "requirements": [], + "dependencies": [], + "codeowners": [], + "version": "1.2.3" +} diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index 76394b42491..bfdc3c3e949 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -1,4 +1,6 @@ """Test aiohttp request helper.""" +import sys + from aiohttp import web from homeassistant.util import aiohttp @@ -48,11 +50,22 @@ def test_serialize_text() -> None: def test_serialize_body_str() -> None: """Test serializing a response with a str as body.""" response = web.Response(status=201, body="Hello") - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": {"Content-Length": "5", "Content-Type": "text/plain; charset=utf-8"}, - } + # TODO: Remove version check with aiohttp 3.9.0 + if sys.version_info >= (3, 12): + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": {"Content-Type": "text/plain; charset=utf-8"}, + } + else: + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": { + "Content-Length": "5", + "Content-Type": "text/plain; charset=utf-8", + }, + } def test_serialize_body_None() -> None: diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 7b0cc916ec7..60f86ee7af4 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -48,7 +48,7 @@ async def test_check_loop_async() -> None: async def test_check_loop_async_integration(caplog: pytest.LogCaptureFixture) -> None: """Test check_loop detects and raises when called from event loop from integration context.""" with pytest.raises(RuntimeError), patch( - "homeassistant.util.async_.extract_stack", + "homeassistant.helpers.frame.extract_stack", return_value=[ Mock( filename="/home/paulus/homeassistant/core.py", @@ -69,10 +69,10 @@ async def test_check_loop_async_integration(caplog: pytest.LogCaptureFixture) -> ): hasync.check_loop(banned_function) assert ( - "Detected blocking call to banned_function inside the event loop. This is " - "causing stability issues. Please report issue for hue doing blocking calls at " - "homeassistant/components/hue/light.py, line 23: self.light.is_on" - in caplog.text + "Detected blocking call to banned_function inside the event loop by integration" + " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on, " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text ) @@ -81,7 +81,7 @@ async def test_check_loop_async_integration_non_strict( ) -> None: """Test check_loop detects when called from event loop from integration context.""" with patch( - "homeassistant.util.async_.extract_stack", + "homeassistant.helpers.frame.extract_stack", return_value=[ Mock( filename="/home/paulus/homeassistant/core.py", @@ -102,17 +102,17 @@ async def test_check_loop_async_integration_non_strict( ): hasync.check_loop(banned_function, strict=False) assert ( - "Detected blocking call to banned_function inside the event loop. This is " - "causing stability issues. Please report issue for hue doing blocking calls at " - "homeassistant/components/hue/light.py, line 23: self.light.is_on" - in caplog.text + "Detected blocking call to banned_function inside the event loop by integration" + " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on, " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text ) async def test_check_loop_async_custom(caplog: pytest.LogCaptureFixture) -> None: """Test check_loop detects when called from event loop with custom component context.""" with pytest.raises(RuntimeError), patch( - "homeassistant.util.async_.extract_stack", + "homeassistant.helpers.frame.extract_stack", return_value=[ Mock( filename="/home/paulus/homeassistant/core.py", @@ -133,10 +133,10 @@ async def test_check_loop_async_custom(caplog: pytest.LogCaptureFixture) -> None ): hasync.check_loop(banned_function) assert ( - "Detected blocking call to banned_function inside the event loop. This is" - " causing stability issues. Please report issue to the custom integration" - " author for hue doing blocking calls at custom_components/hue/light.py, line" - " 23: self.light.is_on" + "Detected blocking call to banned_function inside the event loop by custom " + "integration 'hue' at custom_components/hue/light.py, line 23: self.light.is_on" + ", please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text @@ -183,8 +183,8 @@ async def test_protect_loop_debugger_sleep(caplog: pytest.LogCaptureFixture) -> assert "Detected blocking call inside the event loop" not in caplog.text -async def test_gather_with_concurrency() -> None: - """Test gather_with_concurrency limits the number of running tasks.""" +async def test_gather_with_limited_concurrency() -> None: + """Test gather_with_limited_concurrency limits the number of running tasks.""" runs = 0 now_time = time.time() @@ -198,7 +198,7 @@ async def test_gather_with_concurrency() -> None: await asyncio.sleep(0.1) return runs - results = await hasync.gather_with_concurrency( + results = await hasync.gather_with_limited_concurrency( 2, *(_increment_runs_if_in_time() for i in range(4)) ) diff --git a/tests/util/test_distance.py b/tests/util/test_distance.py deleted file mode 100644 index c6a9d59cb73..00000000000 --- a/tests/util/test_distance.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Test Home Assistant distance utility functions.""" - -import pytest - -from homeassistant.const import UnitOfLength -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.distance as distance_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = UnitOfLength.KILOMETERS - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert distance_util.convert(2, UnitOfLength.METERS, UnitOfLength.METERS) == 2 - assert "use unit_conversion.DistanceConverter instead" in caplog.text - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert ( - distance_util.convert(5, UnitOfLength.KILOMETERS, UnitOfLength.KILOMETERS) == 5 - ) - assert distance_util.convert(2, UnitOfLength.METERS, UnitOfLength.METERS) == 2 - assert ( - distance_util.convert(6, UnitOfLength.CENTIMETERS, UnitOfLength.CENTIMETERS) - == 6 - ) - assert ( - distance_util.convert(3, UnitOfLength.MILLIMETERS, UnitOfLength.MILLIMETERS) - == 3 - ) - assert distance_util.convert(10, UnitOfLength.MILES, UnitOfLength.MILES) == 10 - assert distance_util.convert(9, UnitOfLength.YARDS, UnitOfLength.YARDS) == 9 - assert distance_util.convert(8, UnitOfLength.FEET, UnitOfLength.FEET) == 8 - assert distance_util.convert(7, UnitOfLength.INCHES, UnitOfLength.INCHES) == 7 - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - distance_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - distance_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - distance_util.convert("a", UnitOfLength.KILOMETERS, UnitOfLength.METERS) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 8.04672), - (UnitOfLength.METERS, 8046.72), - (UnitOfLength.CENTIMETERS, 804672.0), - (UnitOfLength.MILLIMETERS, 8046720.0), - (UnitOfLength.YARDS, 8800.0), - (UnitOfLength.FEET, 26400.0008448), - (UnitOfLength.INCHES, 316800.171072), - ], -) -def test_convert_from_miles(unit, expected) -> None: - """Test conversion from miles to other units.""" - miles = 5 - assert distance_util.convert(miles, UnitOfLength.MILES, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 0.0045720000000000005), - (UnitOfLength.METERS, 4.572), - (UnitOfLength.CENTIMETERS, 457.2), - (UnitOfLength.MILLIMETERS, 4572), - (UnitOfLength.MILES, 0.002840908212), - (UnitOfLength.FEET, 15.00000048), - (UnitOfLength.INCHES, 180.0000972), - ], -) -def test_convert_from_yards(unit, expected) -> None: - """Test conversion from yards to other units.""" - yards = 5 - assert distance_util.convert(yards, UnitOfLength.YARDS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 1.524), - (UnitOfLength.METERS, 1524), - (UnitOfLength.CENTIMETERS, 152400.0), - (UnitOfLength.MILLIMETERS, 1524000.0), - (UnitOfLength.MILES, 0.9469694040000001), - (UnitOfLength.YARDS, 1666.66667), - (UnitOfLength.INCHES, 60000.032400000004), - ], -) -def test_convert_from_feet(unit, expected) -> None: - """Test conversion from feet to other units.""" - feet = 5000 - assert distance_util.convert(feet, UnitOfLength.FEET, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 0.127), - (UnitOfLength.METERS, 127.0), - (UnitOfLength.CENTIMETERS, 12700.0), - (UnitOfLength.MILLIMETERS, 127000.0), - (UnitOfLength.MILES, 0.078914117), - (UnitOfLength.YARDS, 138.88889), - (UnitOfLength.FEET, 416.66668), - ], -) -def test_convert_from_inches(unit, expected) -> None: - """Test conversion from inches to other units.""" - inches = 5000 - assert distance_util.convert(inches, UnitOfLength.INCHES, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.METERS, 5000), - (UnitOfLength.CENTIMETERS, 500000), - (UnitOfLength.MILLIMETERS, 5000000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_kilometers(unit, expected) -> None: - """Test conversion from kilometers to other units.""" - km = 5 - assert distance_util.convert(km, UnitOfLength.KILOMETERS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 5), - (UnitOfLength.CENTIMETERS, 500000), - (UnitOfLength.MILLIMETERS, 5000000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_meters(unit, expected) -> None: - """Test conversion from meters to other units.""" - m = 5000 - assert distance_util.convert(m, UnitOfLength.METERS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 5), - (UnitOfLength.METERS, 5000), - (UnitOfLength.MILLIMETERS, 5000000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_centimeters(unit, expected) -> None: - """Test conversion from centimeters to other units.""" - cm = 500000 - assert distance_util.convert(cm, UnitOfLength.CENTIMETERS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 5), - (UnitOfLength.METERS, 5000), - (UnitOfLength.CENTIMETERS, 500000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_millimeters(unit, expected) -> None: - """Test conversion from millimeters to other units.""" - mm = 5000000 - assert distance_util.convert(mm, UnitOfLength.MILLIMETERS, unit) == pytest.approx( - expected - ) diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index 39bf38f56b1..a08311cca4f 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -11,19 +11,6 @@ from homeassistant.core import HomeAssistant, callback, is_callback import homeassistant.util.logging as logging_util -def test_sensitive_data_filter() -> None: - """Test the logging sensitive data filter.""" - log_filter = logging_util.HideSensitiveDataFilter("mock_sensitive") - - clean_record = logging.makeLogRecord({"msg": "clean log data"}) - log_filter.filter(clean_record) - assert clean_record.msg == "clean log data" - - sensitive_record = logging.makeLogRecord({"msg": "mock_sensitive log"}) - log_filter.filter(sensitive_record) - assert sensitive_record.msg == "******* log" - - async def test_logging_with_queue_handler() -> None: """Test logging with HomeAssistantQueueHandler.""" diff --git a/tests/util/test_package.py b/tests/util/test_package.py index ff26cba0dd4..e64ea01ffa8 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -194,33 +194,6 @@ def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv) -> N assert mock_popen.return_value.communicate.call_count == 1 -def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: - """Test install with find-links on not installed package.""" - env = mock_env_copy() - link = "https://wheels-repository" - assert package.install_package(TEST_NEW_REQ, False, find_links=link) - assert mock_popen.call_count == 2 - assert mock_popen.mock_calls[0] == call( - [ - mock_sys.executable, - "-m", - "pip", - "install", - "--quiet", - TEST_NEW_REQ, - "--find-links", - link, - "--prefer-binary", - ], - stdin=PIPE, - stdout=PIPE, - stderr=PIPE, - env=env, - close_fds=False, - ) - assert mock_popen.return_value.communicate.call_count == 1 - - async def test_async_get_user_site(mock_env_copy) -> None: """Test async get user site directory.""" deps_dir = "/deps_dir" diff --git a/tests/util/test_pressure.py b/tests/util/test_pressure.py deleted file mode 100644 index 9bc8e56d78a..00000000000 --- a/tests/util/test_pressure.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Test Home Assistant pressure utility functions.""" -import pytest - -from homeassistant.const import UnitOfPressure -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.pressure as pressure_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = UnitOfPressure.PA - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert pressure_util.convert(2, UnitOfPressure.PA, UnitOfPressure.PA) == 2 - assert "use unit_conversion.PressureConverter instead" in caplog.text - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert pressure_util.convert(2, UnitOfPressure.PA, UnitOfPressure.PA) == 2 - assert pressure_util.convert(3, UnitOfPressure.HPA, UnitOfPressure.HPA) == 3 - assert pressure_util.convert(4, UnitOfPressure.MBAR, UnitOfPressure.MBAR) == 4 - assert pressure_util.convert(5, UnitOfPressure.INHG, UnitOfPressure.INHG) == 5 - assert pressure_util.convert(6, UnitOfPressure.KPA, UnitOfPressure.KPA) == 6 - assert pressure_util.convert(7, UnitOfPressure.CBAR, UnitOfPressure.CBAR) == 7 - assert pressure_util.convert(8, UnitOfPressure.MMHG, UnitOfPressure.MMHG) == 8 - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - pressure_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - pressure_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - pressure_util.convert("a", UnitOfPressure.HPA, UnitOfPressure.INHG) - - -def test_convert_from_hpascals() -> None: - """Test conversion from hPA to other units.""" - hpascals = 1000 - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.PSI - ) == pytest.approx(14.5037743897) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.INHG - ) == pytest.approx(29.5299801647) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.PA - ) == pytest.approx(100000) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.KPA - ) == pytest.approx(100) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.MBAR - ) == pytest.approx(1000) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.CBAR - ) == pytest.approx(100) - - -def test_convert_from_kpascals() -> None: - """Test conversion from hPA to other units.""" - kpascals = 100 - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.PSI - ) == pytest.approx(14.5037743897) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.INHG - ) == pytest.approx(29.5299801647) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.PA - ) == pytest.approx(100000) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.HPA - ) == pytest.approx(1000) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.MBAR - ) == pytest.approx(1000) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.CBAR - ) == pytest.approx(100) - - -def test_convert_from_inhg() -> None: - """Test conversion from inHg to other units.""" - inhg = 30 - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.PSI - ) == pytest.approx(14.7346266155) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.KPA - ) == pytest.approx(101.59167) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.HPA - ) == pytest.approx(1015.9167) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.PA - ) == pytest.approx(101591.67) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.MBAR - ) == pytest.approx(1015.9167) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.CBAR - ) == pytest.approx(101.59167) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.MMHG - ) == pytest.approx(762) - - -def test_convert_from_mmhg() -> None: - """Test conversion from mmHg to other units.""" - inhg = 30 - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.PSI - ) == pytest.approx(0.580103) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.KPA - ) == pytest.approx(3.99967) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.HPA - ) == pytest.approx(39.9967) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.PA - ) == pytest.approx(3999.67) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.MBAR - ) == pytest.approx(39.9967) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.CBAR - ) == pytest.approx(3.99967) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.INHG - ) == pytest.approx(1.181102) diff --git a/tests/util/test_speed.py b/tests/util/test_speed.py deleted file mode 100644 index ae47b4d39cc..00000000000 --- a/tests/util/test_speed.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Test Home Assistant speed utility functions.""" -import pytest - -from homeassistant.const import ( - SPEED_INCHES_PER_DAY, - SPEED_INCHES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, - UnitOfSpeed, -) -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.speed as speed_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = UnitOfSpeed.KILOMETERS_PER_HOUR - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert speed_util.convert(2, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_DAY) == 2 - assert "use unit_conversion.SpeedConverter instead" in caplog.text - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert speed_util.convert(2, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_DAY) == 2 - assert speed_util.convert(3, SPEED_INCHES_PER_HOUR, SPEED_INCHES_PER_HOUR) == 3 - assert ( - speed_util.convert( - 4, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KILOMETERS_PER_HOUR - ) - == 4 - ) - assert ( - speed_util.convert( - 5, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.METERS_PER_SECOND - ) - == 5 - ) - assert ( - speed_util.convert(6, UnitOfSpeed.MILES_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR) - == 6 - ) - assert ( - speed_util.convert(7, SPEED_MILLIMETERS_PER_DAY, SPEED_MILLIMETERS_PER_DAY) == 7 - ) - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - speed_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - speed_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - speed_util.convert( - "a", UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR - ) - - -@pytest.mark.parametrize( - ("from_value", "from_unit", "expected", "to_unit"), - [ - # 5 km/h / 1.609 km/mi = 3.10686 mi/h - (5, UnitOfSpeed.KILOMETERS_PER_HOUR, 3.10686, UnitOfSpeed.MILES_PER_HOUR), - # 5 mi/h * 1.609 km/mi = 8.04672 km/h - (5, UnitOfSpeed.MILES_PER_HOUR, 8.04672, UnitOfSpeed.KILOMETERS_PER_HOUR), - # 5 in/day * 25.4 mm/in = 127 mm/day - (5, SPEED_INCHES_PER_DAY, 127, SPEED_MILLIMETERS_PER_DAY), - # 5 mm/day / 25.4 mm/in = 0.19685 in/day - (5, SPEED_MILLIMETERS_PER_DAY, 0.19685, SPEED_INCHES_PER_DAY), - # 5 in/hr * 24 hr/day = 3048 mm/day - (5, SPEED_INCHES_PER_HOUR, 3048, SPEED_MILLIMETERS_PER_DAY), - # 5 m/s * 39.3701 in/m * 3600 s/hr = 708661 - (5, UnitOfSpeed.METERS_PER_SECOND, 708661, SPEED_INCHES_PER_HOUR), - # 5000 in/h / 39.3701 in/m / 3600 s/h = 0.03528 m/s - (5000, SPEED_INCHES_PER_HOUR, 0.03528, UnitOfSpeed.METERS_PER_SECOND), - # 5 kt * 1852 m/nmi / 3600 s/h = 2.5722 m/s - (5, UnitOfSpeed.KNOTS, 2.5722, UnitOfSpeed.METERS_PER_SECOND), - # 5 ft/s * 0.3048 m/ft = 1.524 m/s - (5, UnitOfSpeed.FEET_PER_SECOND, 1.524, UnitOfSpeed.METERS_PER_SECOND), - ], -) -def test_convert_different_units(from_value, from_unit, expected, to_unit) -> None: - """Test conversion between units.""" - assert speed_util.convert(from_value, from_unit, to_unit) == pytest.approx( - expected, rel=1e-4 - ) diff --git a/tests/util/test_temperature.py b/tests/util/test_temperature.py deleted file mode 100644 index 93edb8f7393..00000000000 --- a/tests/util/test_temperature.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Test Home Assistant temperature utility functions.""" -import pytest - -from homeassistant.const import UnitOfTemperature -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.temperature as temperature_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = UnitOfTemperature.CELSIUS - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert ( - temperature_util.convert( - 2, UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS - ) - == 2 - ) - assert "use unit_conversion.TemperatureConverter instead" in caplog.text - - -@pytest.mark.parametrize( - ("function_name", "value", "expected"), - [ - ("fahrenheit_to_celsius", 75.2, 24), - ("kelvin_to_celsius", 297.65, 24.5), - ("celsius_to_fahrenheit", 23, 73.4), - ("celsius_to_kelvin", 23, 296.15), - ], -) -def test_deprecated_functions( - function_name: str, value: float, expected: float -) -> None: - """Test that deprecated function still work.""" - convert = getattr(temperature_util, function_name) - assert convert(value) == expected - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert ( - temperature_util.convert( - 2, UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS - ) - == 2 - ) - assert ( - temperature_util.convert( - 3, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.FAHRENHEIT - ) - == 3 - ) - assert ( - temperature_util.convert(4, UnitOfTemperature.KELVIN, UnitOfTemperature.KELVIN) - == 4 - ) - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - temperature_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - temperature_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - temperature_util.convert( - "a", UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT - ) - - -def test_convert_from_celsius() -> None: - """Test conversion from C to other units.""" - celsius = 100 - assert temperature_util.convert( - celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT - ) == pytest.approx(212.0) - assert temperature_util.convert( - celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.KELVIN - ) == pytest.approx(373.15) - # Interval - assert temperature_util.convert( - celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT, True - ) == pytest.approx(180.0) - assert temperature_util.convert( - celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.KELVIN, True - ) == pytest.approx(100) - - -def test_convert_from_fahrenheit() -> None: - """Test conversion from F to other units.""" - fahrenheit = 100 - assert temperature_util.convert( - fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS - ) == pytest.approx(37.77777777777778) - assert temperature_util.convert( - fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.KELVIN - ) == pytest.approx(310.92777777777775) - # Interval - assert temperature_util.convert( - fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS, True - ) == pytest.approx(55.55555555555556) - assert temperature_util.convert( - fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.KELVIN, True - ) == pytest.approx(55.55555555555556) - - -def test_convert_from_kelvin() -> None: - """Test conversion from K to other units.""" - kelvin = 100 - assert temperature_util.convert( - kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.CELSIUS - ) == pytest.approx(-173.15) - assert temperature_util.convert( - kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.FAHRENHEIT - ) == pytest.approx(-279.66999999999996) - # Interval - assert temperature_util.convert( - kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.FAHRENHEIT, True - ) == pytest.approx(180.0) - assert temperature_util.convert( - kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.KELVIN, True - ) == pytest.approx(100) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 18f0c9a12c1..e7affecfaf4 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -105,7 +105,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo VolumeConverter: (UnitOfVolume.GALLONS, UnitOfVolume.LITERS, 0.264172), } -# Dict containing a conversion test for every know unit. +# Dict containing a conversion test for every known unit. _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { diff --git a/tests/util/test_volume.py b/tests/util/test_volume.py deleted file mode 100644 index f8a73929b70..00000000000 --- a/tests/util/test_volume.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Test Home Assistant volume utility functions.""" - -import pytest - -from homeassistant.const import ( - VOLUME_CUBIC_FEET, - VOLUME_CUBIC_METERS, - VOLUME_FLUID_OUNCE, - VOLUME_GALLONS, - VOLUME_LITERS, - VOLUME_MILLILITERS, -) -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.volume as volume_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = VOLUME_LITERS - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert volume_util.convert(2, VOLUME_LITERS, VOLUME_LITERS) == 2 - assert "use unit_conversion.VolumeConverter instead" in caplog.text - - -@pytest.mark.parametrize( - ("function_name", "value", "expected"), - [ - ("liter_to_gallon", 2, pytest.approx(0.528344)), - ("gallon_to_liter", 2, 7.570823568), - ("cubic_meter_to_cubic_feet", 2, pytest.approx(70.629333)), - ("cubic_feet_to_cubic_meter", 2, pytest.approx(0.0566337)), - ], -) -def test_deprecated_functions( - function_name: str, value: float, expected: float -) -> None: - """Test that deprecated function still work.""" - convert = getattr(volume_util, function_name) - assert convert(value) == expected - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert volume_util.convert(2, VOLUME_LITERS, VOLUME_LITERS) == 2 - assert volume_util.convert(3, VOLUME_MILLILITERS, VOLUME_MILLILITERS) == 3 - assert volume_util.convert(4, VOLUME_GALLONS, VOLUME_GALLONS) == 4 - assert volume_util.convert(5, VOLUME_FLUID_OUNCE, VOLUME_FLUID_OUNCE) == 5 - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - volume_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - volume_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - volume_util.convert("a", VOLUME_GALLONS, VOLUME_LITERS) - - -def test_convert_from_liters() -> None: - """Test conversion from liters to other units.""" - liters = 5 - assert volume_util.convert(liters, VOLUME_LITERS, VOLUME_GALLONS) == pytest.approx( - 1.32086 - ) - - -def test_convert_from_gallons() -> None: - """Test conversion from gallons to other units.""" - gallons = 5 - assert volume_util.convert(gallons, VOLUME_GALLONS, VOLUME_LITERS) == pytest.approx( - 18.92706 - ) - - -def test_convert_from_cubic_meters() -> None: - """Test conversion from cubic meter to other units.""" - cubic_meters = 5 - assert volume_util.convert( - cubic_meters, VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET - ) == pytest.approx(176.5733335) - - -def test_convert_from_cubic_feet() -> None: - """Test conversion from cubic feet to cubic meters to other units.""" - cubic_feets = 500 - assert volume_util.convert( - cubic_feets, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS - ) == pytest.approx(14.1584233) - - -@pytest.mark.parametrize( - ("source_unit", "target_unit", "expected"), - [ - (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, 14.1584233), - (VOLUME_CUBIC_FEET, VOLUME_FLUID_OUNCE, 478753.2467), - (VOLUME_CUBIC_FEET, VOLUME_GALLONS, 3740.25974), - (VOLUME_CUBIC_FEET, VOLUME_LITERS, 14158.42329599), - (VOLUME_CUBIC_FEET, VOLUME_MILLILITERS, 14158423.29599), - (VOLUME_CUBIC_METERS, VOLUME_CUBIC_METERS, 500), - (VOLUME_CUBIC_METERS, VOLUME_FLUID_OUNCE, 16907011.35), - (VOLUME_CUBIC_METERS, VOLUME_GALLONS, 132086.02617), - (VOLUME_CUBIC_METERS, VOLUME_LITERS, 500000), - (VOLUME_CUBIC_METERS, VOLUME_MILLILITERS, 500000000), - (VOLUME_FLUID_OUNCE, VOLUME_CUBIC_FEET, 0.52218967), - (VOLUME_FLUID_OUNCE, VOLUME_CUBIC_METERS, 0.014786764), - (VOLUME_FLUID_OUNCE, VOLUME_GALLONS, 3.90625), - (VOLUME_FLUID_OUNCE, VOLUME_LITERS, 14.786764), - (VOLUME_FLUID_OUNCE, VOLUME_MILLILITERS, 14786.764), - (VOLUME_GALLONS, VOLUME_CUBIC_FEET, 66.84027), - (VOLUME_GALLONS, VOLUME_CUBIC_METERS, 1.892706), - (VOLUME_GALLONS, VOLUME_FLUID_OUNCE, 64000), - (VOLUME_GALLONS, VOLUME_LITERS, 1892.70589), - (VOLUME_GALLONS, VOLUME_MILLILITERS, 1892705.89), - (VOLUME_LITERS, VOLUME_CUBIC_FEET, 17.65733), - (VOLUME_LITERS, VOLUME_CUBIC_METERS, 0.5), - (VOLUME_LITERS, VOLUME_FLUID_OUNCE, 16907.011), - (VOLUME_LITERS, VOLUME_GALLONS, 132.086), - (VOLUME_LITERS, VOLUME_MILLILITERS, 500000), - (VOLUME_MILLILITERS, VOLUME_CUBIC_FEET, 0.01765733), - (VOLUME_MILLILITERS, VOLUME_CUBIC_METERS, 0.0005), - (VOLUME_MILLILITERS, VOLUME_FLUID_OUNCE, 16.907), - (VOLUME_MILLILITERS, VOLUME_GALLONS, 0.132086), - (VOLUME_MILLILITERS, VOLUME_LITERS, 0.5), - ], -) -def test_convert(source_unit, target_unit, expected) -> None: - """Test conversion between units.""" - value = 500 - assert volume_util.convert(value, source_unit, target_unit) == pytest.approx( - expected - )