diff --git a/.core_files.yaml b/.core_files.yaml index 9af81c59934..b1870654be0 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -27,6 +27,7 @@ base_platforms: &base_platforms - homeassistant/components/fan/** - homeassistant/components/geo_location/** - homeassistant/components/humidifier/** + - homeassistant/components/image/** - homeassistant/components/image_processing/** - homeassistant/components/light/** - homeassistant/components/lock/** diff --git a/.coveragerc b/.coveragerc index 8494ef357bf..6a2a0db3ea4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -90,6 +90,8 @@ omit = homeassistant/components/atome/* homeassistant/components/aurora/__init__.py homeassistant/components/aurora/binary_sensor.py + homeassistant/components/aurora/coordinator.py + homeassistant/components/aurora/entity.py homeassistant/components/aurora/sensor.py homeassistant/components/avea/light.py homeassistant/components/avion/light.py @@ -122,7 +124,6 @@ omit = homeassistant/components/bluetooth_tracker/* homeassistant/components/bmw_connected_drive/__init__.py homeassistant/components/bmw_connected_drive/binary_sensor.py - homeassistant/components/bmw_connected_drive/button.py homeassistant/components/bmw_connected_drive/coordinator.py homeassistant/components/bmw_connected_drive/lock.py homeassistant/components/bmw_connected_drive/notify.py @@ -202,6 +203,9 @@ omit = homeassistant/components/discogs/sensor.py homeassistant/components/discord/__init__.py homeassistant/components/discord/notify.py + homeassistant/components/discovergy/__init__.py + homeassistant/components/discovergy/sensor.py + homeassistant/components/discovergy/coordinator.py homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/data.py @@ -302,22 +306,10 @@ omit = homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py homeassistant/components/esphome/__init__.py - homeassistant/components/esphome/binary_sensor.py homeassistant/components/esphome/bluetooth/* - homeassistant/components/esphome/button.py homeassistant/components/esphome/camera.py - homeassistant/components/esphome/climate.py - homeassistant/components/esphome/cover.py homeassistant/components/esphome/domain_data.py homeassistant/components/esphome/entry_data.py - homeassistant/components/esphome/fan.py - homeassistant/components/esphome/light.py - homeassistant/components/esphome/lock.py - homeassistant/components/esphome/media_player.py - homeassistant/components/esphome/number.py - homeassistant/components/esphome/select.py - homeassistant/components/esphome/sensor.py - homeassistant/components/esphome/switch.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* homeassistant/components/eufylife_ble/__init__.py @@ -327,6 +319,7 @@ omit = homeassistant/components/ezviz/__init__.py homeassistant/components/ezviz/binary_sensor.py homeassistant/components/ezviz/camera.py + homeassistant/components/ezviz/light.py homeassistant/components/ezviz/coordinator.py homeassistant/components/ezviz/number.py homeassistant/components/ezviz/entity.py @@ -362,10 +355,13 @@ omit = homeassistant/components/fitbit/* homeassistant/components/fivem/__init__.py homeassistant/components/fivem/binary_sensor.py + homeassistant/components/fivem/coordinator.py + homeassistant/components/fivem/entity.py homeassistant/components/fivem/sensor.py homeassistant/components/fixer/sensor.py homeassistant/components/fjaraskupan/__init__.py homeassistant/components/fjaraskupan/binary_sensor.py + homeassistant/components/fjaraskupan/coordinator.py homeassistant/components/fjaraskupan/fan.py homeassistant/components/fjaraskupan/light.py homeassistant/components/fjaraskupan/number.py @@ -615,7 +611,6 @@ omit = homeassistant/components/kwb/sensor.py homeassistant/components/lacrosse/sensor.py homeassistant/components/lannouncer/notify.py - homeassistant/components/lastfm/sensor.py homeassistant/components/launch_library/__init__.py homeassistant/components/launch_library/sensor.py homeassistant/components/lcn/climate.py @@ -946,6 +941,8 @@ omit = homeassistant/components/pyload/sensor.py homeassistant/components/qbittorrent/__init__.py homeassistant/components/qbittorrent/sensor.py + homeassistant/components/qnap/__init__.py + homeassistant/components/qnap/coordinator.py homeassistant/components/qnap/sensor.py homeassistant/components/qrcode/image_processing.py homeassistant/components/quantum_gateway/device_tracker.py @@ -973,6 +970,10 @@ omit = homeassistant/components/rainmachine/switch.py homeassistant/components/rainmachine/update.py homeassistant/components/rainmachine/util.py + homeassistant/components/renson/__init__.py + homeassistant/components/renson/const.py + homeassistant/components/renson/entity.py + homeassistant/components/renson/sensor.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py homeassistant/components/recorder/repack.py @@ -1046,12 +1047,6 @@ omit = homeassistant/components/sense/__init__.py homeassistant/components/sense/binary_sensor.py homeassistant/components/sense/sensor.py - homeassistant/components/senseme/__init__.py - homeassistant/components/senseme/discovery.py - homeassistant/components/senseme/entity.py - homeassistant/components/senseme/fan.py - homeassistant/components/senseme/light.py - homeassistant/components/senseme/switch.py homeassistant/components/senz/__init__.py homeassistant/components/senz/api.py homeassistant/components/senz/climate.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 063342cc6b7..2ee32ca9dbc 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -10,7 +10,7 @@ on: env: BUILD_TYPE: core - DEFAULT_PYTHON: "3.10" + DEFAULT_PYTHON: "3.11" jobs: init: @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 with: fetch-depth: 0 @@ -48,18 +48,6 @@ jobs: with: ignore-dev: true - - name: Generate meta info - shell: bash - run: | - echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > OFFICIAL_IMAGE - - - name: Signing meta info file - uses: home-assistant/actions/helpers/codenotary@master - with: - source: file://${{ github.workspace }}/OFFICIAL_IMAGE - asset: OFFICIAL_IMAGE-${{ steps.version.outputs.version }} - token: ${{ secrets.CAS_TOKEN }} - build_python: name: Build PyPi package environment: ${{ needs.init.outputs.channel }} @@ -68,7 +56,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 @@ -101,12 +89,16 @@ jobs: if: github.repository_owner == 'home-assistant' needs: init runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write strategy: matrix: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -182,7 +174,7 @@ jobs: # will drop the platform in the near future (they consider it # "flimsy" on 386). The following packages depend on pandas, # so we comment them out. - sed -i "s|env_canada|# env_canada|g" requirements_all.txt + sed -i "s|env-canada|# env-canada|g" requirements_all.txt sed -i "s|noaa-coops|# noaa-coops|g" requirements_all.txt sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt @@ -197,25 +189,20 @@ jobs: run: | echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - - name: Login to DockerHub - uses: docker/login-action@v2.1.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GitHub Container Registry - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.03.0 + uses: home-assistant/builder@2023.06.1 with: args: | $BUILD_ARGS \ --${{ matrix.arch }} \ + --cosign \ --target /data \ --generic ${{ needs.init.outputs.version }} env: @@ -237,6 +224,10 @@ jobs: if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write strategy: matrix: machine: @@ -262,7 +253,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set build additional args run: | @@ -275,25 +266,20 @@ jobs: echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV fi - - name: Login to DockerHub - uses: docker/login-action@v2.1.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GitHub Container Registry - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.03.0 + uses: home-assistant/builder@2023.06.1 with: args: | $BUILD_ARGS \ --target /data/machine \ + --cosign \ --machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}" env: CAS_API_KEY: ${{ secrets.CAS_TOKEN }} @@ -306,7 +292,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -338,34 +324,32 @@ jobs: if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - registry: - - "ghcr.io/home-assistant" - - "homeassistant" + permissions: + contents: read + packages: write + id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 + + - name: Install Cosign + uses: sigstore/cosign-installer@v3.1.1 + with: + cosign-release: "v2.0.2" - name: Login to DockerHub - if: matrix.registry == 'homeassistant' - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v2.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Install CAS tools - uses: home-assistant/actions/helpers/cas@master - - name: Build Meta Image shell: bash run: | @@ -375,55 +359,78 @@ jobs: local tag_l=${1} local tag_r=${2} - docker manifest create "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \ - "${{ matrix.registry }}/i386-homeassistant:${tag_r}" \ - "${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \ - "${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \ - "${{ matrix.registry }}/aarch64-homeassistant:${tag_r}" + for registry in "ghcr.io/home-assistant" "docker.io/homeassistant" + do - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \ - --os linux --arch amd64 + docker manifest create "${registry}/home-assistant:${tag_l}" \ + "${registry}/amd64-homeassistant:${tag_r}" \ + "${registry}/i386-homeassistant:${tag_r}" \ + "${registry}/armhf-homeassistant:${tag_r}" \ + "${registry}/armv7-homeassistant:${tag_r}" \ + "${registry}/aarch64-homeassistant:${tag_r}" - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/i386-homeassistant:${tag_r}" \ - --os linux --arch 386 + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/amd64-homeassistant:${tag_r}" \ + --os linux --arch amd64 - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \ - --os linux --arch arm --variant=v6 + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/i386-homeassistant:${tag_r}" \ + --os linux --arch 386 - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \ - --os linux --arch arm --variant=v7 + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/armhf-homeassistant:${tag_r}" \ + --os linux --arch arm --variant=v6 - docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \ - "${{ matrix.registry }}/aarch64-homeassistant:${tag_r}" \ - --os linux --arch arm64 --variant=v8 + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/armv7-homeassistant:${tag_r}" \ + --os linux --arch arm --variant=v7 - docker manifest push --purge "${{ matrix.registry }}/home-assistant:${tag_l}" + docker manifest annotate "${registry}/home-assistant:${tag_l}" \ + "${registry}/aarch64-homeassistant:${tag_r}" \ + --os linux --arch arm64 --variant=v8 + + docker manifest push --purge "${registry}/home-assistant:${tag_l}" + cosign sign --yes "${registry}/home-assistant:${tag_l}" + + done } function validate_image() { local image=${1} - if ! cas authenticate --signerID notary@home-assistant.io "docker://${image}"; then + if ! cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp https://github.com/home-assistant/core/.* "${image}"; then echo "Invalid signature!" exit 1 fi } - docker pull "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}" - docker pull "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}" - docker pull "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}" - docker pull "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}" - docker pull "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}" + function push_dockerhub() { + local image=${1} + local tag=${2} - validate_image "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}" - validate_image "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}" - validate_image "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}" - validate_image "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}" - validate_image "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}" + docker tag "ghcr.io/home-assistant/${image}:${tag}" "docker.io/homeassistant/${image}:${tag}" + docker push "docker.io/homeassistant/${image}:${tag}" + cosign sign --yes "docker.io/homeassistant/${image}:${tag}" + } + + # Pull images from github container registry and verify signature + docker pull "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}" + docker pull "ghcr.io/home-assistant/i386-homeassistant:${{ needs.init.outputs.version }}" + docker pull "ghcr.io/home-assistant/armhf-homeassistant:${{ needs.init.outputs.version }}" + docker pull "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}" + docker pull "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}" + + validate_image "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}" + validate_image "ghcr.io/home-assistant/i386-homeassistant:${{ needs.init.outputs.version }}" + validate_image "ghcr.io/home-assistant/armhf-homeassistant:${{ needs.init.outputs.version }}" + validate_image "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}" + validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}" + + # Upload images to dockerhub + push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}" + push_dockerhub "i386-homeassistant" "${{ needs.init.outputs.version }}" + push_dockerhub "armhf-homeassistant" "${{ needs.init.outputs.version }}" + push_dockerhub "armv7-homeassistant" "${{ needs.init.outputs.version }}" + push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}" # Create version tag create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0438e674dd..bcc19bfb55d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.6 + HA_SHORT_VERSION: 2023.7 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version @@ -82,7 +82,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -206,7 +206,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -251,7 +251,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 id: python @@ -297,7 +297,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 id: python @@ -338,44 +338,6 @@ jobs: shopt -s globstar pre-commit run --hook-stage manual ruff --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - lint-isort: - name: Check isort - runs-on: ubuntu-22.04 - needs: - - info - - pre-commit - steps: - - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.1 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - check-latest: true - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache/restore@v3.3.1 - with: - path: venv - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache/restore@v3.3.1 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Run isort - run: | - . venv/bin/activate - pre-commit run --hook-stage manual isort --all-files --show-diff-on-failure - lint-other: name: Check other linters runs-on: ubuntu-22.04 @@ -384,7 +346,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 id: python @@ -468,19 +430,6 @@ jobs: with: args: hadolint Dockerfile.dev - - name: Run bandit (fully) - if: needs.info.outputs.test_full_suite == 'true' - run: | - . venv/bin/activate - pre-commit run --hook-stage manual bandit --all-files --show-diff-on-failure - - name: Run bandit (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual bandit --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - base: name: Prepare dependencies runs-on: ubuntu-22.04 @@ -491,7 +440,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.6.1 @@ -543,10 +492,10 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.2" setuptools wheel + pip install --cache-dir=$PIP_CACHE -U "pip>=21.3.1,<23.2" setuptools wheel pip install --cache-dir=$PIP_CACHE -r requirements_all.txt pip install --cache-dir=$PIP_CACHE -r requirements_test.txt - pip install -e . + pip install -e . --config-settings editable_mode=compat hassfest: name: Check hassfest @@ -559,7 +508,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -591,7 +540,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -624,7 +573,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -668,7 +617,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.6.1 @@ -732,7 +681,6 @@ jobs: - base - gen-requirements-all - hassfest - - lint-isort - lint-other - lint-ruff - mypy @@ -751,7 +699,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.6.1 @@ -857,7 +805,6 @@ jobs: - base - gen-requirements-all - hassfest - - lint-isort - lint-other - lint-ruff - mypy @@ -877,7 +824,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.6.1 @@ -965,7 +912,6 @@ jobs: - base - gen-requirements-all - hassfest - - lint-isort - lint-other - lint-ruff - mypy @@ -985,7 +931,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.6.1 @@ -1062,12 +1008,12 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) if: needs.info.outputs.test_full_suite == 'true' - uses: Wandalen/wretry.action@v1.0.36 + uses: Wandalen/wretry.action@v1.3.0 with: action: codecov/codecov-action@v3.1.3 with: | @@ -1077,7 +1023,7 @@ jobs: attempt_delay: 30000 - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' - uses: Wandalen/wretry.action@v1.0.36 + uses: Wandalen/wretry.action@v1.3.0 with: action: codecov/codecov-action@v3.1.3 with: | diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index c0593fa3a9a..2b5364fa950 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -10,7 +10,7 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4.0.0 + - uses: dessant/lock-threads@v4.0.1 with: github-token: ${{ github.token }} issue-inactive-days: "30" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 997543f0cf1..dd1f3d061a9 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -10,7 +10,7 @@ on: - "**strings.json" env: - DEFAULT_PYTHON: "3.10" + DEFAULT_PYTHON: "3.11" jobs: upload: @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.6.1 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c735a446938..16bd347d7cf 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -26,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Get information id: info @@ -47,10 +47,7 @@ jobs: echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true" echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true" echo "GRPC_PYTHON_DISABLE_LIBC_COMPATIBILITY=true" - # GRPC on armv7 needs -lexecinfo (issue #56669) since home assistant installs - # execinfo-dev when building wheels. The setuptools build setup does not have an option for - # adding a single LDFLAG so copy all relevant linux flags here (as of 1.43.0) - echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc -lexecinfo" + echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc" # Fix out of memory issues with rust echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" @@ -83,11 +80,11 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp310", "cp311"] + abi: ["cp311"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - name: Download env_file uses: actions/download-artifact@v3 @@ -113,100 +110,6 @@ jobs: requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" - integrations_cp310: - name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} - if: github.repository_owner == 'home-assistant' - needs: init - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - abi: ["cp310"] - arch: ${{ fromJson(needs.init.outputs.architectures) }} - steps: - - name: Checkout the repository - uses: actions/checkout@v3.5.2 - - - name: Download env_file - uses: actions/download-artifact@v3 - with: - name: env_file - - - name: Download requirements_diff - uses: actions/download-artifact@v3 - with: - name: requirements_diff - - - name: Uncomment packages - run: | - requirement_files="requirements_all.txt requirements_diff.txt" - for requirement_file in ${requirement_files}; do - sed -i "s|# pybluez|pybluez|g" ${requirement_file} - sed -i "s|# beacontools|beacontools|g" ${requirement_file} - sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} - sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} - sed -i "s|# evdev|evdev|g" ${requirement_file} - sed -i "s|# pycups|pycups|g" ${requirement_file} - sed -i "s|# homekit|homekit|g" ${requirement_file} - sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} - sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} - sed -i "s|# opencv-python-headless|opencv-python-headless|g" ${requirement_file} - done - - - name: Split requirements all - run: | - # We split requirements all into two different files. - # This is to prevent the build from running out of memory when - # resolving packages on 32-bits systems (like armhf, armv7). - - split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 2) requirements_all.txt requirements_all.txt - - - name: Adjust build env - run: | - if [ "${{ matrix.arch }}" = "i386" ]; then - echo "NPY_DISABLE_SVML=1" >> .env_file - fi - - ( - # cmake > 3.22.2 have issue on arm - # Tested until 3.22.5 - echo "cmake==3.22.2" - ) >> homeassistant/package_constraints.txt - - # Do not pin numpy in wheels building - sed -i "/numpy/d" homeassistant/package_constraints.txt - - - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.04.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;sqlalchemy;protobuf - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtaa" - - - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.04.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;sqlalchemy;protobuf - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtab" - - # Wheels building for the cp311 ABI is currently split - # This is mainly until we have figured out to get all wheels built. - # Without harming our current workflow. integrations_cp311: name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} if: github.repository_owner == 'home-assistant' @@ -219,31 +122,12 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.2 + uses: actions/checkout@v3.5.3 - - name: Write alternative env-file for cp311 - run: | - ( - echo "GRPC_BUILD_WITH_BORING_SSL_ASM=false" - echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true" - echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true" - echo "GRPC_PYTHON_DISABLE_LIBC_COMPATIBILITY=true" - - # GRPC on armv7 needed -lexecinfo (issue #56669) since home assistant installed - # execinfo-dev when building wheels. However, this package is no longer available - # Alpine 3.17, which we use for the cp311 ABI, so the flag should no longer be needed. - echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc" # -lexecinfo - - # Fix out of memory issues with rust - echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" - - # OpenCV headless installation - echo "CI_BUILD=1" - echo "ENABLE_HEADLESS=1" - - # Use C-Extension for sqlalchemy - echo "REQUIRE_SQLALCHEMY_CEXT=1" - ) > .env_file + - name: Download env_file + uses: actions/download-artifact@v3 + with: + name: env_file - name: Download requirements_diff uses: actions/download-artifact@v3 @@ -254,27 +138,12 @@ jobs: run: | requirement_files="requirements_all.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do - - # PyBluez no longer compiles. Commented it out for now. - # It need further cleanup down the line, as all machine images - # try to install it. - # sed -i "s|# pybluez|pybluez|g" ${requirement_file} - - # beacontools requires PyBluez. - # sed -i "s|# beacontools|beacontools|g" ${requirement_file} - - # It doesn't build for some reason, so we skip it for now. - # Bumping to the latest version (4.7.0.72) supporting Python 3.11 - # doesn't help. Reverted bump in #91871. There are 8 registered - # instances using this integration according to analytics. - # sed -i "s|# opencv-python-headless|opencv-python-headless|g" ${requirement_file} - sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} sed -i "s|# evdev|evdev|g" ${requirement_file} sed -i "s|# pycups|pycups|g" ${requirement_file} sed -i "s|# homekit|homekit|g" ${requirement_file} - sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} + sed -i "s|# decora-wifi|decora-wifi|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} # Some packages are not buildable on armhf anymore @@ -284,7 +153,7 @@ jobs: # will drop the platform in the near future (they consider it # "flimsy" on 386). The following packages depend on pandas, # so we comment them out. - sed -i "s|env_canada|# env_canada|g" ${requirement_file} + sed -i "s|env-canada|# env-canada|g" ${requirement_file} sed -i "s|noaa-coops|# noaa-coops|g" ${requirement_file} sed -i "s|pyezviz|# pyezviz|g" ${requirement_file} sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file} @@ -297,7 +166,7 @@ jobs: # This is to prevent the build from running out of memory when # resolving packages on 32-bits systems (like armhf, armv7). - split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 2) requirements_all.txt requirements_all.txt + split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all.txt requirements_all.txt - name: Adjust build env run: | @@ -305,13 +174,6 @@ jobs: echo "NPY_DISABLE_SVML=1" >> .env_file fi - # Probably not an issue anymore. Removing for now. - # ( - # # cmake > 3.22.2 have issue on arm - # # Tested until 3.22.5 - # echo "cmake==3.22.2" - # ) >> homeassistant/package_constraints.txt - # Do not pin numpy in wheels building sed -i "/numpy/d" homeassistant/package_constraints.txt @@ -342,3 +204,17 @@ jobs: constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" + + - name: Build wheels (part 3) + uses: home-assistant/wheels@2023.04.0 + with: + abi: ${{ matrix.abi }} + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_all.txtac" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8e8fef97697..d6cd3f43b10 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.262 + rev: v0.0.272 hooks: - id: ruff args: @@ -22,19 +22,6 @@ repos: - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/|homeassistant/generated/ - - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 - hooks: - - id: bandit - args: - - --quiet - - --format=custom - - --configfile=tests/bandit.yaml - files: ^(homeassistant|script|tests)/.+\.py$ - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: diff --git a/.strict-typing b/.strict-typing index 801827df6dc..67ebca7aea7 100644 --- a/.strict-typing +++ b/.strict-typing @@ -172,6 +172,7 @@ homeassistant.components.huawei_lte.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* +homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.imap.* @@ -243,6 +244,7 @@ homeassistant.components.overkiz.* homeassistant.components.peco.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* +homeassistant.components.ping.* homeassistant.components.powerwall.* homeassistant.components.proximity.* homeassistant.components.prusalink.* @@ -275,7 +277,6 @@ homeassistant.components.scene.* homeassistant.components.schedule.* homeassistant.components.scrape.* homeassistant.components.select.* -homeassistant.components.senseme.* homeassistant.components.sensibo.* homeassistant.components.sensirion_ble.* homeassistant.components.sensor.* @@ -308,6 +309,7 @@ homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.text.* homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* diff --git a/CODEOWNERS b/CODEOWNERS index 44b7e4bce36..3f8f27187f3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -275,6 +275,8 @@ build.json @home-assistant/supervisor /homeassistant/components/discogs/ @thibmaek /homeassistant/components/discord/ @tkdrob /tests/components/discord/ @tkdrob +/homeassistant/components/discovergy/ @jpbede +/tests/components/discovergy/ @jpbede /homeassistant/components/discovery/ @home-assistant/core /tests/components/discovery/ @home-assistant/core /homeassistant/components/dlink/ @tkdrob @@ -289,6 +291,8 @@ build.json @home-assistant/supervisor /tests/components/doorbird/ @oblogic7 @bdraco @flacjacket /homeassistant/components/dormakaba_dkey/ @emontnemery /tests/components/dormakaba_dkey/ @emontnemery +/homeassistant/components/dremel_3d_printer/ @tkdrob +/tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/dsmr/ @Robbie1221 @frenck /tests/components/dsmr/ @Robbie1221 @frenck /homeassistant/components/dsmr_reader/ @depl0y @glodenox @@ -352,8 +356,8 @@ build.json @home-assistant/supervisor /homeassistant/components/eq3btsmart/ @rytilahti /homeassistant/components/escea/ @lazdavila /tests/components/escea/ @lazdavila -/homeassistant/components/esphome/ @OttoWinter @jesserockz -/tests/components/esphome/ @OttoWinter @jesserockz +/homeassistant/components/esphome/ @OttoWinter @jesserockz @bdraco +/tests/components/esphome/ @OttoWinter @jesserockz @bdraco /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 /homeassistant/components/evil_genius_labs/ @balloob @@ -448,8 +452,8 @@ build.json @home-assistant/supervisor /tests/components/glances/ @engrbm87 /homeassistant/components/goalzero/ @tkdrob /tests/components/goalzero/ @tkdrob -/homeassistant/components/gogogate2/ @vangorra @bdraco -/tests/components/gogogate2/ @vangorra @bdraco +/homeassistant/components/gogogate2/ @vangorra +/tests/components/gogogate2/ @vangorra /homeassistant/components/goodwe/ @mletenay @starkillerOG /tests/components/goodwe/ @mletenay @starkillerOG /homeassistant/components/google/ @allenporter @@ -559,12 +563,14 @@ build.json @home-assistant/supervisor /tests/components/icloud/ @Quentame @nzapponi /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte +/homeassistant/components/image/ @home-assistant/core +/tests/components/image/ @home-assistant/core /homeassistant/components/image_processing/ @home-assistant/core /tests/components/image_processing/ @home-assistant/core /homeassistant/components/image_upload/ @home-assistant/core /tests/components/image_upload/ @home-assistant/core -/homeassistant/components/imap/ @engrbm87 @jbouwh -/tests/components/imap/ @engrbm87 @jbouwh +/homeassistant/components/imap/ @jbouwh +/tests/components/imap/ @jbouwh /homeassistant/components/incomfort/ @zxdavb /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 @@ -697,6 +703,8 @@ build.json @home-assistant/supervisor /tests/components/logi_circle/ @evanjd /homeassistant/components/lookin/ @ANMalko @bdraco /tests/components/lookin/ @ANMalko @bdraco +/homeassistant/components/loqed/ @mikewoudenberg +/tests/components/loqed/ @mikewoudenberg /homeassistant/components/lovelace/ @home-assistant/frontend /tests/components/lovelace/ @home-assistant/frontend /homeassistant/components/luci/ @mzdrale @@ -779,11 +787,12 @@ build.json @home-assistant/supervisor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core -/homeassistant/components/myq/ @bdraco @ehendrix23 -/tests/components/myq/ @bdraco @ehendrix23 +/homeassistant/components/myq/ @ehendrix23 +/tests/components/myq/ @ehendrix23 /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff +/tests/components/mystrom/ @fabaff /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu /homeassistant/components/nanoleaf/ @milanmeu @@ -878,6 +887,7 @@ build.json @home-assistant/supervisor /homeassistant/components/opengarage/ @danielhiversen /tests/components/opengarage/ @danielhiversen /homeassistant/components/openhome/ @bazwilliams +/tests/components/openhome/ @bazwilliams /homeassistant/components/opensky/ @joostlek /homeassistant/components/opentherm_gw/ @mvn23 /tests/components/opentherm_gw/ @mvn23 @@ -961,6 +971,8 @@ build.json @home-assistant/supervisor /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte /tests/components/qld_bushfire/ @exxamalte +/homeassistant/components/qnap/ @disforw +/tests/components/qnap/ @disforw /homeassistant/components/qnap_qsw/ @Noltari /tests/components/qnap_qsw/ @Noltari /homeassistant/components/quantum_gateway/ @cisasteelersfan @@ -999,6 +1011,8 @@ build.json @home-assistant/supervisor /tests/components/remote/ @home-assistant/core /homeassistant/components/renault/ @epenet /tests/components/renault/ @epenet +/homeassistant/components/renson/ @jimmyd-be +/tests/components/renson/ @jimmyd-be /homeassistant/components/reolink/ @starkillerOG /tests/components/reolink/ @starkillerOG /homeassistant/components/repairs/ @home-assistant/core @@ -1068,8 +1082,6 @@ build.json @home-assistant/supervisor /tests/components/select/ @home-assistant/core /homeassistant/components/sense/ @kbickar /tests/components/sense/ @kbickar -/homeassistant/components/senseme/ @mikelawrence @bdraco -/tests/components/senseme/ @mikelawrence @bdraco /homeassistant/components/sensibo/ @andrey-git @gjohansson-ST /tests/components/sensibo/ @andrey-git @gjohansson-ST /homeassistant/components/sensirion_ble/ @akx @@ -1292,6 +1304,8 @@ build.json @home-assistant/supervisor /tests/components/twentemilieu/ @frenck /homeassistant/components/twinkly/ @dr1rrb @Robbie1221 /tests/components/twinkly/ @dr1rrb @Robbie1221 +/homeassistant/components/twitch/ @joostlek +/tests/components/twitch/ @joostlek /homeassistant/components/ukraine_alarm/ @PaulAnnekov /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 diff --git a/Dockerfile.dev b/Dockerfile.dev index de49bb77f12..857ccfa3997 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.10 +FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.11 SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/README.rst b/README.rst index 084949dc44e..0dc98a379a3 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ If you run into issues while using Home Assistant or during development of a component, check the `Home Assistant help section `__ of our website for further help and information. .. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg - :target: https://discord.gg/c5DvZ4e + :target: https://www.home-assistant.io/join-chat/ .. |screenshot-states| image:: https://raw.githubusercontent.com/home-assistant/core/master/docs/screenshots.png :target: https://demo.home-assistant.io .. |screenshot-integrations| image:: https://raw.githubusercontent.com/home-assistant/core/dev/docs/screenshot-integrations.png diff --git a/build.yaml b/build.yaml index b32aa38dff6..a181e9d1548 100644 --- a/build.yaml +++ b/build.yaml @@ -1,14 +1,16 @@ -image: homeassistant/{arch}-homeassistant -shadow_repository: ghcr.io/home-assistant +image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.06.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.06.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.06.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.06.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.06.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.06.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.06.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.06.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.06.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.06.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io +cosign: + base_identity: https://github.com/home-assistant/docker/.* + identity: https://github.com/home-assistant/core/.* labels: io.hass.type: core org.opencontainers.image.title: Home Assistant diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index f63d6d465f6..bfe8a2fdddb 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -13,8 +13,8 @@ from homeassistant.const import CONF_COMMAND from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow CONF_ARGS = "args" CONF_META = "meta" diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 050a0660a6b..6f621b93a6a 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -16,8 +16,8 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.storage import Store -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow STORAGE_VERSION = 1 STORAGE_KEY = "auth_provider.homeassistant" diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index 47626686f9d..f7f01e74c27 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -11,8 +11,8 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow USER_SCHEMA = vol.Schema( { diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 72ba3b1ecb3..0cadbf07589 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -15,8 +15,8 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow AUTH_PROVIDER_TYPE = "legacy_api_password" CONF_API_PASSWORD = "api_password" diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 04db5fc287b..6962671cb2f 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -23,9 +23,9 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from .. import InvalidAuthError from ..models import Credentials, RefreshToken, UserMeta +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow IPAddress = IPv4Address | IPv6Address IPNetwork = IPv4Network | IPv6Network diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py new file mode 100644 index 00000000000..c7ab0d08693 --- /dev/null +++ b/homeassistant/backports/functools.py @@ -0,0 +1,72 @@ +"""Functools backports from standard lib.""" +from __future__ import annotations + +from collections.abc import Callable +from types import GenericAlias +from typing import Any, Generic, TypeVar, overload + +from typing_extensions import Self + +_T = TypeVar("_T") +_R = TypeVar("_R") + + +class cached_property(Generic[_T, _R]): # pylint: disable=invalid-name + """Backport of Python 3.12's cached_property. + + Includes https://github.com/python/cpython/pull/101890/files + """ + + def __init__(self, func: Callable[[_T], _R]) -> None: + """Initialize.""" + self.func = func + self.attrname: Any = None + self.__doc__ = func.__doc__ + + def __set_name__(self, owner: type[_T], name: str) -> None: + """Set name.""" + if self.attrname is None: + self.attrname = name + elif name != self.attrname: + raise TypeError( + "Cannot assign the same cached_property to two different names " + f"({self.attrname!r} and {name!r})." + ) + + @overload + def __get__(self, instance: None, owner: type[_T]) -> Self: + ... + + @overload + def __get__(self, instance: _T, owner: type[_T]) -> _R: + ... + + def __get__(self, instance: _T | None, owner: type[_T] | None = None) -> _R | Self: + """Get.""" + if instance is None: + return self + if self.attrname is None: + raise TypeError( + "Cannot use cached_property instance without calling __set_name__ on it." + ) + try: + cache = instance.__dict__ + # not all objects have __dict__ (e.g. class defines slots) + except AttributeError: + msg = ( + f"No '__dict__' attribute on {type(instance).__name__!r} " + f"instance to cache {self.attrname!r} property." + ) + raise TypeError(msg) from None + val = self.func(instance) + try: + cache[self.attrname] = val + except TypeError: + msg = ( + f"The '__dict__' attribute on {type(instance).__name__!r} instance " + f"does not support item assignment for caching {self.attrname!r} property." + ) + raise TypeError(msg) from None + return val + + __class_getitem__ = classmethod(GenericAlias) diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index 2546f762912..66a2e3b0db5 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -34,6 +34,7 @@ class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity): """An alarm_control_panel implementation for Abode.""" _attr_icon = ICON + _attr_name = None _attr_code_arm_required = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index 402b636e5d6..a10dbc8e664 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -42,6 +42,7 @@ async def async_setup_entry( class AbodeBinarySensor(AbodeDevice, BinarySensorEntity): """A binary sensor implementation for Abode device.""" + _attr_name = None _device: ABBinarySensor @property diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 17d7b820d45..afe017bfcc7 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -39,6 +39,7 @@ class AbodeCamera(AbodeDevice, Camera): """Representation of an Abode camera.""" _device: AbodeCam + _attr_name = None def __init__(self, data: AbodeSystem, device: AbodeDev, event: Event) -> None: """Initialize the Abode device.""" diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py index 507b1284362..d504040ee90 100644 --- a/homeassistant/components/abode/cover.py +++ b/homeassistant/components/abode/cover.py @@ -29,6 +29,7 @@ class AbodeCover(AbodeDevice, CoverEntity): """Representation of an Abode cover.""" _device: AbodeCV + _attr_name = None @property def is_closed(self) -> bool: diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index be69897431f..539b89a5546 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -42,6 +42,7 @@ class AbodeLight(AbodeDevice, LightEntity): """Representation of an Abode light.""" _device: AbodeLT + _attr_name = None def turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py index 039b2423099..c110b3fd558 100644 --- a/homeassistant/components/abode/lock.py +++ b/homeassistant/components/abode/lock.py @@ -29,6 +29,7 @@ class AbodeLock(AbodeDevice, LockEntity): """Representation of an Abode lock.""" _device: AbodeLK + _attr_name = None def lock(self, **kwargs: Any) -> None: """Lock the device.""" diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index 546d57ab3e7..1e238783221 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -22,17 +22,14 @@ from .const import DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=CONST.TEMP_STATUS_KEY, - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key=CONST.HUMI_STATUS_KEY, - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, ), SensorEntityDescription( key=CONST.LUX_STATUS_KEY, - name="Lux", device_class=SensorDeviceClass.ILLUMINANCE, ), ) diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index ab83e3a20c1..14bdf4e0caf 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -44,6 +44,7 @@ class AbodeSwitch(AbodeDevice, SwitchEntity): """Representation of an Abode switch.""" _device: AbodeSW + _attr_name = None def turn_on(self, **kwargs: Any) -> None: """Turn on the device.""" diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 6107285e376..20cb12179ee 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -4,10 +4,13 @@ from __future__ import annotations from typing import cast from homeassistant.components.weather import ( + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, @@ -29,7 +32,16 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp from . import AccuWeatherDataUpdateCoordinator -from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN +from .const import ( + API_METRIC, + ATTR_DIRECTION, + ATTR_FORECAST, + ATTR_SPEED, + ATTR_VALUE, + ATTRIBUTION, + CONDITION_CLASSES, + DOMAIN, +) PARALLEL_UPDATES = 1 @@ -50,6 +62,7 @@ class AccuWeatherEntity( """Define an AccuWeather entity.""" _attr_has_entity_name = True + _attr_name = None def __init__(self, coordinator: AccuWeatherDataUpdateCoordinator) -> None: """Initialize.""" @@ -78,35 +91,61 @@ class AccuWeatherEntity( except IndexError: return None + @property + def cloud_coverage(self) -> float: + """Return the Cloud coverage in %.""" + return cast(float, self.coordinator.data["CloudCover"]) + + @property + def native_apparent_temperature(self) -> float: + """Return the apparent temperature.""" + return cast( + float, self.coordinator.data["ApparentTemperature"][API_METRIC][ATTR_VALUE] + ) + @property def native_temperature(self) -> float: """Return the temperature.""" - return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"]) + return cast(float, self.coordinator.data["Temperature"][API_METRIC][ATTR_VALUE]) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"]) + return cast(float, self.coordinator.data["Pressure"][API_METRIC][ATTR_VALUE]) + + @property + def native_dew_point(self) -> float: + """Return the dew point.""" + return cast(float, self.coordinator.data["DewPoint"][API_METRIC][ATTR_VALUE]) @property def humidity(self) -> int: """Return the humidity.""" return cast(int, self.coordinator.data["RelativeHumidity"]) + @property + def native_wind_gust_speed(self) -> float: + """Return the wind gust speed.""" + return cast( + float, self.coordinator.data["WindGust"][ATTR_SPEED][API_METRIC][ATTR_VALUE] + ) + @property def native_wind_speed(self) -> float: """Return the wind speed.""" - return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"]) + return cast( + float, self.coordinator.data["Wind"][ATTR_SPEED][API_METRIC][ATTR_VALUE] + ) @property def wind_bearing(self) -> int: """Return the wind bearing.""" - return cast(int, self.coordinator.data["Wind"]["Direction"]["Degrees"]) + return cast(int, self.coordinator.data["Wind"][ATTR_DIRECTION]["Degrees"]) @property def native_visibility(self) -> float: """Return the visibility.""" - return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"]) + return cast(float, self.coordinator.data["Visibility"][API_METRIC][ATTR_VALUE]) @property def forecast(self) -> list[Forecast] | None: @@ -117,14 +156,23 @@ class AccuWeatherEntity( return [ { ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), - ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"]["Value"], - ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"]["Value"], - ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquidDay"]["Value"], + ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCoverDay"], + ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"][ATTR_VALUE], + ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"][ATTR_VALUE], + ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperatureMax"][ + ATTR_VALUE + ], + ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquidDay"][ATTR_VALUE], ATTR_FORECAST_PRECIPITATION_PROBABILITY: item[ "PrecipitationProbabilityDay" ], - ATTR_FORECAST_NATIVE_WIND_SPEED: item["WindDay"]["Speed"]["Value"], - ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], + ATTR_FORECAST_NATIVE_WIND_SPEED: item["WindDay"][ATTR_SPEED][ + ATTR_VALUE + ], + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item["WindGustDay"][ATTR_SPEED][ + ATTR_VALUE + ], + ATTR_FORECAST_WIND_BEARING: item["WindDay"][ATTR_DIRECTION]["Degrees"], ATTR_FORECAST_CONDITION: [ k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v ][0], diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py index 138587bbad3..2fc106f75f5 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/base.py @@ -14,6 +14,7 @@ class AcmedaBase(entity.Entity): """Base representation of an Acmeda roller.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, roller: aiopulse.Roller) -> None: """Initialize the roller.""" @@ -72,11 +73,6 @@ class AcmedaBase(entity.Entity): """Return the ID of this roller.""" return self.roller.id - @property - def name(self) -> str | None: - """Return the name of roller.""" - return self.roller.name - @property def device_info(self) -> entity.DeviceInfo: """Return the device info.""" diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 15a20cf6932..2af985033b6 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -45,7 +45,9 @@ async def async_setup_entry( class AcmedaCover(AcmedaBase, CoverEntity): - """Representation of a Acmeda cover device.""" + """Representation of an Acmeda cover device.""" + + _attr_name = None @property def current_cover_position(self) -> int | None: diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index f92d9fcf57b..e8ccb30ada4 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -40,16 +40,11 @@ async def async_setup_entry( class AcmedaBattery(AcmedaBase, SensorEntity): - """Representation of a Acmeda cover device.""" + """Representation of an Acmeda cover sensor.""" _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE - @property - def name(self) -> str: - """Return the name of roller.""" - return f"{super().name} Battery" - @property def native_value(self) -> float | int | None: """Return the state of the device.""" diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index cc15872dafa..0db6a3615f6 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -1,7 +1,7 @@ """Support for Adax wifi-enabled home heaters.""" from __future__ import annotations -from typing import Any +from typing import Any, cast from adax import Adax from adax_local import Adax as AdaxLocal @@ -79,7 +79,10 @@ class AdaxDevice(ClimateEntity): self._attr_unique_id = f"{heater_data['homeId']}_{heater_data['id']}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, heater_data["id"])}, - name=self.name, + # Instead of setting the device name to the entity name, adax + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), manufacturer="Adax", ) diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index f24aa20d28d..9f1c0a5b0fe 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -39,56 +39,56 @@ class AdGuardHomeEntityDescription( SENSORS: tuple[AdGuardHomeEntityDescription, ...] = ( AdGuardHomeEntityDescription( key="dns_queries", - name="DNS queries", + translation_key="dns_queries", icon="mdi:magnify", native_unit_of_measurement="queries", value_fn=lambda adguard: adguard.stats.dns_queries(), ), AdGuardHomeEntityDescription( key="blocked_filtering", - name="DNS queries blocked", + translation_key="dns_queries_blocked", icon="mdi:magnify-close", native_unit_of_measurement="queries", value_fn=lambda adguard: adguard.stats.blocked_filtering(), ), AdGuardHomeEntityDescription( key="blocked_percentage", - name="DNS queries blocked ratio", + translation_key="dns_queries_blocked_ratio", icon="mdi:magnify-close", native_unit_of_measurement=PERCENTAGE, value_fn=lambda adguard: adguard.stats.blocked_percentage(), ), AdGuardHomeEntityDescription( key="blocked_parental", - name="Parental control blocked", + translation_key="parental_control_blocked", icon="mdi:human-male-girl", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_parental(), ), AdGuardHomeEntityDescription( key="blocked_safebrowsing", - name="Safe browsing blocked", + translation_key="safe_browsing_blocked", icon="mdi:shield-half-full", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_safebrowsing(), ), AdGuardHomeEntityDescription( key="enforced_safesearch", - name="Safe searches enforced", + translation_key="safe_searches_enforced", icon="mdi:shield-search", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_safesearch(), ), AdGuardHomeEntityDescription( key="average_speed", - name="Average processing speed", + translation_key="average_processing_speed", icon="mdi:speedometer", native_unit_of_measurement=UnitOfTime.MILLISECONDS, value_fn=lambda adguard: adguard.stats.avg_processing_time(), ), AdGuardHomeEntityDescription( key="rules_count", - name="Rules count", + translation_key="rules_count", icon="mdi:counter", native_unit_of_measurement="rules", value_fn=lambda adguard: adguard.filtering.rules_count(allowlist=False), diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json index e593d4199a4..bde73e82b37 100644 --- a/homeassistant/components/adguard/strings.json +++ b/homeassistant/components/adguard/strings.json @@ -24,5 +24,53 @@ "existing_instance_updated": "Updated existing configuration.", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "sensor": { + "dns_queries": { + "name": "DNS queries" + }, + "dns_queries_blocked": { + "name": "DNS queries blocked" + }, + "dns_queries_blocked_ratio": { + "name": "DNS queries blocked ratio" + }, + "parental_control_blocked": { + "name": "Parental control blocked" + }, + "safe_browsing_blocked": { + "name": "Safe browsing blocked" + }, + "safe_searches_enforced": { + "name": "Safe searches enforced" + }, + "average_processing_speed": { + "name": "Average processing speed" + }, + "rules_count": { + "name": "Rules count" + } + }, + "switch": { + "protection": { + "name": "Protection" + }, + "parental": { + "name": "Parental control" + }, + "safe_search": { + "name": "Safe search" + }, + "safe_browsing": { + "name": "Safe browsing" + }, + "filtering": { + "name": "Filtering" + }, + "query_log": { + "name": "Query log" + } + } } } diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index a359bf86c2d..1020e8690f1 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -40,7 +40,7 @@ class AdGuardHomeSwitchEntityDescription( SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="protection", - name="Protection", + translation_key="protection", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.protection_enabled, turn_on_fn=lambda adguard: adguard.enable_protection, @@ -48,7 +48,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="parental", - name="Parental control", + translation_key="parental", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.parental.enabled, turn_on_fn=lambda adguard: adguard.parental.enable, @@ -56,7 +56,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="safesearch", - name="Safe search", + translation_key="safe_search", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.safesearch.enabled, turn_on_fn=lambda adguard: adguard.safesearch.enable, @@ -64,7 +64,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="safebrowsing", - name="Safe browsing", + translation_key="safe_browsing", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.safebrowsing.enabled, turn_on_fn=lambda adguard: adguard.safebrowsing.enable, @@ -72,7 +72,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="filtering", - name="Filtering", + translation_key="filtering", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.filtering.enabled, turn_on_fn=lambda adguard: adguard.filtering.enable, @@ -80,7 +80,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( ), AdGuardHomeSwitchEntityDescription( key="querylog", - name="Query log", + translation_key="query_log", icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.querylog.enabled, turn_on_fn=lambda adguard: adguard.querylog.enable, diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 76d73f75a8b..17aede2bd2b 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -10,6 +10,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from .. import ads from . import ( ADS_TYPEMAP, CONF_ADS_FACTOR, @@ -18,7 +19,6 @@ from . import ( STATE_KEY_STATE, AdsEntity, ) -from .. import ads DEFAULT_NAME = "ADS sensor" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 6170bd165e9..fa9f609ba10 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -90,6 +90,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 + _attr_name = None _attr_hvac_modes = [ HVACMode.OFF, diff --git a/homeassistant/components/advantage_air/diagnostics.py b/homeassistant/components/advantage_air/diagnostics.py index 27eaef09b43..4c440610838 100644 --- a/homeassistant/components/advantage_air/diagnostics.py +++ b/homeassistant/components/advantage_air/diagnostics.py @@ -9,17 +9,30 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN -TO_REDACT = ["dealerPhoneNumber", "latitude", "logoPIN", "longitude", "postCode"] +TO_REDACT = [ + "dealerPhoneNumber", + "latitude", + "logoPIN", + "longitude", + "postCode", + "rid", + "deviceNames", + "deviceIds", + "deviceIdsV2", + "backupId", +] async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id]["coordinator"].data + data = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id].coordinator.data # Return only the relevant children return { - "aircons": data["aircons"], + "aircons": data.get("aircons"), + "myLights": data.get("myLights"), + "myThings": data.get("myThings"), "system": async_redact_data(data["system"], TO_REDACT), } diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index bbc8738c4ae..9e4f92e8c98 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -84,6 +84,8 @@ class AdvantageAirZoneEntity(AdvantageAirAcEntity): class AdvantageAirThingEntity(AdvantageAirEntity): """Parent class for Advantage Air Things Entities.""" + _attr_name = None + def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None: """Initialize common aspects of an Advantage Air Things entity.""" super().__init__(instance) diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index 13a77d5cab3..7815354dd92 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -41,6 +41,7 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): """Representation of Advantage Air Light.""" _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_name = None def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: """Initialize an Advantage Air Light.""" diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json index ed9d3bff989..a07d14896eb 100644 --- a/homeassistant/components/advantage_air/manifest.json +++ b/homeassistant/components/advantage_air/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["advantage_air"], "quality_scale": "platinum", - "requirements": ["advantage_air==0.4.4"] + "requirements": ["advantage-air==0.4.4"] } diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 53e15c651a7..cfbe7b98883 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -80,7 +80,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM1, device_class=SensorDeviceClass.PM1, - translation_key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -88,7 +87,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM25, device_class=SensorDeviceClass.PM25, - translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -100,7 +98,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM10, device_class=SensorDeviceClass.PM10, - translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -112,7 +109,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, - translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -120,7 +116,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PRESSURE, device_class=SensorDeviceClass.PRESSURE, - translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -128,7 +123,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, - translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -147,7 +141,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_NO2, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, - translation_key="no2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -159,7 +152,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_SO2, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, - translation_key="so2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -171,7 +163,6 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_O3, device_class=SensorDeviceClass.OZONE, - translation_key="o3", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 50ebdd6d4dd..7ec58ccd8e5 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -32,35 +32,8 @@ "caqi": { "name": "Common air quality index" }, - "pm1": { - "name": "[%key:component::sensor::entity_component::pm1::name%]" - }, - "pm25": { - "name": "[%key:component::sensor::entity_component::pm25::name%]" - }, - "pm10": { - "name": "[%key:component::sensor::entity_component::pm10::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, - "pressure": { - "name": "[%key:component::sensor::entity_component::pressure::name%]" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, "co": { "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" - }, - "no2": { - "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" - }, - "so2": { - "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" - }, - "o3": { - "name": "[%key:component::sensor::entity_component::ozone::name%]" } } } diff --git a/homeassistant/components/airnow/const.py b/homeassistant/components/airnow/const.py index 67a9289efc5..34b1f4392bc 100644 --- a/homeassistant/components/airnow/const.py +++ b/homeassistant/components/airnow/const.py @@ -17,5 +17,3 @@ ATTR_API_STATION_LATITUDE = "Latitude" ATTR_API_STATION_LONGITUDE = "Longitude" DEFAULT_NAME = "AirNow" DOMAIN = "airnow" -SENSOR_AQI_ATTR_DESCR = "description" -SENSOR_AQI_ATTR_LEVEL = "level" diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index decec74ee47..f3d29cc65df 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -1,7 +1,12 @@ """Support for the AirNow sensor service.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, @@ -12,7 +17,10 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AirNowDataUpdateCoordinator @@ -22,36 +30,60 @@ from .const import ( ATTR_API_AQI_LEVEL, ATTR_API_O3, ATTR_API_PM25, + DEFAULT_NAME, DOMAIN, - SENSOR_AQI_ATTR_DESCR, - SENSOR_AQI_ATTR_LEVEL, ) ATTRIBUTION = "Data provided by AirNow" PARALLEL_UPDATES = 1 -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +ATTR_DESCR = "description" +ATTR_LEVEL = "level" + + +@dataclass +class AirNowEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], StateType] + extra_state_attributes_fn: Callable[[Any], dict[str, str]] | None + + +@dataclass +class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMixin): + """Describes Airnow sensor entity.""" + + +SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( + AirNowEntityDescription( key=ATTR_API_AQI, icon="mdi:blur", - name=ATTR_API_AQI, - native_unit_of_measurement="aqi", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.AQI, + value_fn=lambda data: data.get(ATTR_API_AQI), + extra_state_attributes_fn=lambda data: { + ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION], + ATTR_LEVEL: data[ATTR_API_AQI_LEVEL], + }, ), - SensorEntityDescription( + AirNowEntityDescription( key=ATTR_API_PM25, icon="mdi:blur", - name=ATTR_API_PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PM25, + value_fn=lambda data: data.get(ATTR_API_PM25), + extra_state_attributes_fn=None, ), - SensorEntityDescription( + AirNowEntityDescription( key=ATTR_API_O3, + translation_key="o3", icon="mdi:blur", - name=ATTR_API_O3, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.get(ATTR_API_O3), + extra_state_attributes_fn=None, ), ) @@ -73,38 +105,38 @@ class AirNowSensor(CoordinatorEntity[AirNowDataUpdateCoordinator], SensorEntity) """Define an AirNow sensor.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + entity_description: AirNowEntityDescription def __init__( self, coordinator: AirNowDataUpdateCoordinator, - description: SensorEntityDescription, + description: AirNowEntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) self.entity_description = description - self._state = None - self._attrs: dict[str, str] = {} - self._attr_name = f"AirNow {description.name}" self._attr_unique_id = ( f"{coordinator.latitude}-{coordinator.longitude}-{description.key.lower()}" ) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + ) @property - def native_value(self): + def native_value(self) -> StateType: """Return the state.""" - self._state = self.coordinator.data.get(self.entity_description.key) - - return self._state + return self.entity_description.value_fn(self.coordinator.data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes.""" - if self.entity_description.key == ATTR_API_AQI: - self._attrs[SENSOR_AQI_ATTR_DESCR] = self.coordinator.data[ - ATTR_API_AQI_DESCRIPTION - ] - self._attrs[SENSOR_AQI_ATTR_LEVEL] = self.coordinator.data[ - ATTR_API_AQI_LEVEL - ] - - return self._attrs + if self.entity_description.extra_state_attributes_fn: + return self.entity_description.extra_state_attributes_fn( + self.coordinator.data + ) + return None diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index 0e86c4531dc..aed12596176 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -20,5 +20,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "o3": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + } + } } } diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index 7f0d51fcaa8..9974307b4cd 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -53,63 +53,62 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="c2h4o", - name="Acetaldehyde", + translation_key="acetaldehyde", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c2h4o"), ), AirQEntityDescription( key="nh3_MR100", - name="Ammonia", + translation_key="ammonia", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("nh3_MR100"), ), AirQEntityDescription( key="ash3", - name="Arsine", + translation_key="arsine", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ash3"), ), AirQEntityDescription( key="br2", - name="Bromine", + translation_key="bromine", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("br2"), ), AirQEntityDescription( key="ch4s", - name="CH4S", + translation_key="methanethiol", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch4s"), ), AirQEntityDescription( key="cl2_M20", - name="Chlorine", + translation_key="chlorine", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("cl2_M20"), ), AirQEntityDescription( key="clo2", - name="ClO2", + translation_key="chlorine_dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("clo2"), ), AirQEntityDescription( key="co", - name="CO", + translation_key="carbon_monoxide", native_unit_of_measurement=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co"), ), AirQEntityDescription( key="co2", - name="CO2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, @@ -117,14 +116,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="cs2", - name="CS2", + translation_key="carbon_disulfide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("cs2"), ), AirQEntityDescription( key="dewpt", - name="Dew point", + translation_key="dew_point", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("dewpt"), @@ -132,63 +131,63 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="ethanol", - name="Ethanol", + translation_key="ethanol", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ethanol"), ), AirQEntityDescription( key="c2h4", - name="Ethylene", + translation_key="ethylene", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c2h4"), ), AirQEntityDescription( key="ch2o_M10", - name="Formaldehyde", + translation_key="formaldehyde", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch2o_M10"), ), AirQEntityDescription( key="f2", - name="Fluorine", + translation_key="fluorine", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("f2"), ), AirQEntityDescription( key="h2s", - name="H2S", + translation_key="hydrogen_sulfide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2s"), ), AirQEntityDescription( key="hcl", - name="HCl", + translation_key="hydrochloric_acid", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("hcl"), ), AirQEntityDescription( key="hcn", - name="HCN", + translation_key="hydrogen_cyanide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("hcn"), ), AirQEntityDescription( key="hf", - name="HF", + translation_key="hydrogen_fluoride", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("hf"), ), AirQEntityDescription( key="health", - name="Health Index", + translation_key="health_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:heart-pulse", @@ -196,7 +195,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -204,7 +202,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="humidity_abs", - name="Absolute humidity", + translation_key="absolute_humidity", native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("humidity_abs"), @@ -212,28 +210,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="h2_M1000", - name="Hydrogen", + translation_key="hydrogen", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2_M1000"), ), AirQEntityDescription( key="h2o2", - name="Hydrogen peroxide", + translation_key="hydrogen_peroxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2o2"), ), AirQEntityDescription( key="ch4_MIPEX", - name="Methane", + translation_key="methane", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch4_MIPEX"), ), AirQEntityDescription( key="n2o", - name="N2O", device_class=SensorDeviceClass.NITROUS_OXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -241,7 +238,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="no_M250", - name="NO", device_class=SensorDeviceClass.NITROGEN_MONOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -249,7 +245,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="no2", - name="NO2", device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -257,14 +252,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="acid_M100", - name="Organic acid", + translation_key="organic_acid", native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("acid_M100"), ), AirQEntityDescription( key="oxygen", - name="Oxygen", + translation_key="oxygen", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("oxygen"), @@ -272,7 +267,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="o3", - name="Ozone", device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -280,7 +274,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="performance", - name="Performance Index", + translation_key="performance_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:head-check", @@ -288,14 +282,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="ph3", - name="PH3", + translation_key="hydrogen_phosphide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ph3"), ), AirQEntityDescription( key="pm1", - name="PM1", device_class=SensorDeviceClass.PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -304,7 +297,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pm2_5", - name="PM2.5", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -313,7 +305,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pm10", - name="PM10", device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -322,7 +313,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pressure", - name="Pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, @@ -330,7 +320,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="pressure_rel", - name="Relative pressure", + translation_key="relative_pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("pressure_rel"), @@ -338,28 +328,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="c3h8_MIPEX", - name="Propane", + translation_key="propane", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c3h8_MIPEX"), ), AirQEntityDescription( key="refigerant", - name="Refrigerant", + translation_key="refigerant", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("refigerant"), ), AirQEntityDescription( key="sih4", - name="SiH4", + translation_key="silicon_hydride", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("sih4"), ), AirQEntityDescription( key="so2", - name="SO2", device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -367,7 +356,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="sound", - name="Noise", + translation_key="noise", native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("sound"), @@ -375,7 +364,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="sound_max", - name="Noise (Maximum)", + translation_key="maximum_noise", native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("sound_max"), @@ -383,7 +372,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="radon", - name="Radon", + translation_key="radon", native_unit_of_measurement=ACTIVITY_BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("radon"), @@ -391,7 +380,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -399,21 +387,22 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="tvoc", - name="VOC", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc"), ), AirQEntityDescription( key="tvoc_ionsc", - name="VOC (Industrial)", + translation_key="industrial_volatile_organic_compounds", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), ), AirQEntityDescription( key="virus", - name="Virus Index", + translation_key="virus_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:virus-off", diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 3618d9d517e..8628ede4116 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -18,5 +18,117 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "acetaldehyde": { + "name": "Acetaldehyde" + }, + "ammonia": { + "name": "Ammonia" + }, + "arsine": { + "name": "Arsine" + }, + "bromine": { + "name": "Bromine" + }, + "methanethiol": { + "name": "Methanethiol" + }, + "chlorine": { + "name": "Chlorine" + }, + "chlorine_dioxide": { + "name": "Chlorine dioxide" + }, + "carbon_disulfide": { + "name": "Carbon disulfide" + }, + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "dew_point": { + "name": "Dew point" + }, + "ethanol": { + "name": "Ethanol" + }, + "ethylene": { + "name": "Ethylene" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "fluorine": { + "name": "Fluorine" + }, + "hydrogen_sulfide": { + "name": "Hydrogen sulfide" + }, + "hydrochloric_acid": { + "name": "Hydrochloric acid" + }, + "hydrogen_cyanide": { + "name": "Hydrogen cyanide" + }, + "hydrogen_fluoride": { + "name": "Hydrogen fluoride" + }, + "health_index": { + "name": "Health Index" + }, + "absolute_humidity": { + "name": "Absolute humidity" + }, + "hydrogen": { + "name": "Hydrogen" + }, + "hydrogen_peroxide": { + "name": "Hydrogen peroxide" + }, + "methane": { + "name": "Methane" + }, + "organic_acid": { + "name": "Organic acid" + }, + "oxygen": { + "name": "Oxygen" + }, + "performance_index": { + "name": "Performance Index" + }, + "hydrogen_phosphide": { + "name": "Hydrogen Phosphide" + }, + "relative_pressure": { + "name": "Relative pressure" + }, + "propane": { + "name": "Propane" + }, + "refigerant": { + "name": "Refrigerant" + }, + "silicon_hydride": { + "name": "Silicon Hydride" + }, + "noise": { + "name": "Noise" + }, + "maximum_noise": { + "name": "Noise (Maximum)" + }, + "radon": { + "name": "Radon" + }, + "industrial_volatile_organic_compounds": { + "name": "VOCs (Industrial)" + }, + "virus_index": { + "name": "Virus Index" + } + } } } diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json index 6e30048d844..da7f30679c6 100644 --- a/homeassistant/components/airthings/manifest.json +++ b/homeassistant/components/airthings/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airthings", "iot_class": "cloud_polling", "loggers": ["airthings"], - "requirements": ["airthings_cloud==0.1.0"] + "requirements": ["airthings-cloud==0.1.0"] } diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 5212ff51fe8..9c9859306ca 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -35,62 +35,56 @@ SENSORS: dict[str, SensorEntityDescription] = { "radonShortTermAvg": SensorEntityDescription( key="radonShortTermAvg", native_unit_of_measurement="Bq/m³", - name="Radon", + translation_key="radon", ), "temp": SensorEntityDescription( key="temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Temperature", ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, - name="Humidity", ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, - name="Pressure", ), "battery": SensorEntityDescription( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - name="Battery", ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - name="CO2", ), "voc": SensorEntityDescription( key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, - name="VOC", ), "light": SensorEntityDescription( key="light", native_unit_of_measurement=PERCENTAGE, - name="Light", + translation_key="light", ), "virusRisk": SensorEntityDescription( key="virusRisk", - name="Virus Risk", + translation_key="virus_risk", ), "mold": SensorEntityDescription( key="mold", - name="Mold", + translation_key="mold", ), "rssi": SensorEntityDescription( key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, device_class=SensorDeviceClass.SIGNAL_STRENGTH, - name="RSSI", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -98,13 +92,11 @@ SENSORS: dict[str, SensorEntityDescription] = { key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, - name="PM1", ), "pm25": SensorEntityDescription( key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, - name="PM25", ), } @@ -134,6 +126,7 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): """Representation of a Airthings Sensor device.""" _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True def __init__( self, @@ -146,7 +139,6 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): self.entity_description = entity_description - self._attr_name = f"{airthings_device.name} {entity_description.name}" self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}" self._id = airthings_device.device_id self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json index af1200baa58..610891fff10 100644 --- a/homeassistant/components/airthings/strings.json +++ b/homeassistant/components/airthings/strings.json @@ -17,5 +17,21 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } + }, + "entity": { + "sensor": { + "radon": { + "name": "Radon" + }, + "light": { + "name": "Light" + }, + "virus_risk": { + "name": "Virus Risk" + }, + "mold": { + "name": "Mold" + } + } } } diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index b6c8c25491b..98190df6b8d 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -39,26 +39,26 @@ _LOGGER = logging.getLogger(__name__) SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { "radon_1day_avg": SensorEntityDescription( key="radon_1day_avg", + translation_key="radon_1day_avg", native_unit_of_measurement=VOLUME_BECQUEREL, - name="Radon 1-day average", state_class=SensorStateClass.MEASUREMENT, icon="mdi:radioactive", ), "radon_longterm_avg": SensorEntityDescription( key="radon_longterm_avg", + translation_key="radon_longterm_avg", native_unit_of_measurement=VOLUME_BECQUEREL, - name="Radon longterm average", state_class=SensorStateClass.MEASUREMENT, icon="mdi:radioactive", ), "radon_1day_level": SensorEntityDescription( key="radon_1day_level", - name="Radon 1-day level", + translation_key="radon_1day_level", icon="mdi:radioactive", ), "radon_longterm_level": SensorEntityDescription( key="radon_longterm_level", - name="Radon longterm level", + translation_key="radon_longterm_level", icon="mdi:radioactive", ), "temperature": SensorEntityDescription( @@ -66,21 +66,18 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - name="Temperature", ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - name="Humidity", ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, - name="Pressure", ), "battery": SensorEntityDescription( key="battery", @@ -88,20 +85,18 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - name="Battery", ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, - name="co2", ), "voc": SensorEntityDescription( key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - name="VOC", icon="mdi:cloud", ), "illuminance": SensorEntityDescription( @@ -109,7 +104,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, state_class=SensorStateClass.MEASUREMENT, - name="Illuminance", ), } diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index 1cfc4ccd592..b1159e6f251 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -19,5 +19,21 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "radon_1day_avg": { + "name": "Radon 1-day average" + }, + "radon_longterm_avg": { + "name": "Radon longterm average" + }, + "radon_1day_level": { + "name": "Radon 1-day level" + }, + "radon_longterm_level": { + "name": "Radon longterm level" + } + } } } diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json index 18183eee197..0ba99c0984a 100644 --- a/homeassistant/components/airvisual/strings.json +++ b/homeassistant/components/airvisual/strings.json @@ -17,7 +17,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "city": "City", "country": "Country", - "state": "state" + "state": "State" } }, "reauth_confirm": { diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 3c47c333b92..74a564fa2de 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -193,6 +193,8 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" + slave_raise = False + params = {} if hvac_mode == HVACMode.OFF: params[API_ON] = 0 @@ -202,12 +204,13 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): if self.get_airzone_value(AZD_MASTER): params[API_MODE] = mode else: - raise HomeAssistantError( - f"Mode can't be changed on slave zone {self.name}" - ) + slave_raise = True params[API_ON] = 1 await self._async_update_hvac_params(params) + if slave_raise: + raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}") + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" params = {} diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index cdc0f30a533..732f159c381 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -12,7 +12,10 @@ from homeassistant.helpers import aiohttp_client from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py new file mode 100644 index 00000000000..052318b6b10 --- /dev/null +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -0,0 +1,106 @@ +"""Support for the Airzone Cloud binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Final + +from aioairzone_cloud.const import AZD_PROBLEMS, AZD_WARNINGS, AZD_ZONES + +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 + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneEntity, AirzoneZoneEntity + + +@dataclass +class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): + """A class that describes Airzone Cloud binary sensor entities.""" + + attributes: dict[str, str] | None = None + + +ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]] = ( + AirzoneBinarySensorEntityDescription( + attributes={ + "warnings": AZD_WARNINGS, + }, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + key=AZD_PROBLEMS, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone Cloud binary sensors from a config_entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + binary_sensors: list[AirzoneBinarySensor] = [] + + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): + for description in ZONE_BINARY_SENSOR_TYPES: + if description.key in zone_data: + binary_sensors.append( + AirzoneZoneBinarySensor( + coordinator, + description, + zone_id, + zone_data, + ) + ) + + async_add_entities(binary_sensors) + + +class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity): + """Define an Airzone Cloud binary sensor.""" + + entity_description: AirzoneBinarySensorEntityDescription + + @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 binary sensor attributes.""" + self._attr_is_on = self.get_airzone_value(self.entity_description.key) + if self.entity_description.attributes: + self._attr_extra_state_attributes = { + key: self.get_airzone_value(val) + for key, val in self.entity_description.attributes.items() + } + + +class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): + """Define an Airzone Cloud Zone binary sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: AirzoneBinarySensorEntityDescription, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, zone_id, zone_data) + + self._attr_unique_id = f"{zone_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index c7e59ee1a3f..9b3dfdae06c 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -15,7 +15,6 @@ from aioairzone_cloud.const import ( AZD_ZONES, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -43,7 +42,6 @@ class AirzoneAidooEntity(AirzoneEntity): def __init__( self, coordinator: AirzoneUpdateCoordinator, - entry: ConfigEntry, aidoo_id: str, aidoo_data: dict[str, Any], ) -> None: @@ -73,7 +71,6 @@ class AirzoneWebServerEntity(AirzoneEntity): def __init__( self, coordinator: AirzoneUpdateCoordinator, - entry: ConfigEntry, ws_id: str, ws_data: dict[str, Any], ) -> None: @@ -86,7 +83,7 @@ class AirzoneWebServerEntity(AirzoneEntity): connections={(dr.CONNECTION_NETWORK_MAC, ws_id)}, identifiers={(DOMAIN, ws_id)}, manufacturer=MANUFACTURER, - name=f"WebServer {ws_id}", + name=ws_data[AZD_NAME], sw_version=ws_data[AZD_FIRMWARE], ) @@ -104,7 +101,6 @@ class AirzoneZoneEntity(AirzoneEntity): def __init__( self, coordinator: AirzoneUpdateCoordinator, - entry: ConfigEntry, zone_id: str, zone_data: dict[str, Any], ) -> None: diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index e64a5d9a7e2..8602dfa14cf 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.1.8"] + "requirements": ["aioairzone-cloud==0.2.0"] } diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index ee162ef5fec..90fbf849389 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -6,7 +6,6 @@ from typing import Any, Final from aioairzone_cloud.const import ( AZD_AIDOOS, AZD_HUMIDITY, - AZD_NAME, AZD_TEMP, AZD_WEBSERVERS, AZD_WIFI_RSSI, @@ -42,7 +41,6 @@ AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, key=AZD_TEMP, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), @@ -53,9 +51,7 @@ WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_entity_name=True, key=AZD_WIFI_RSSI, - name="RSSI", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, ), @@ -65,14 +61,12 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, key=AZD_TEMP, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( device_class=SensorDeviceClass.HUMIDITY, key=AZD_HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), @@ -95,7 +89,6 @@ async def async_setup_entry( AirzoneAidooSensor( coordinator, description, - entry, aidoo_id, aidoo_data, ) @@ -109,7 +102,6 @@ async def async_setup_entry( AirzoneWebServerSensor( coordinator, description, - entry, ws_id, ws_data, ) @@ -123,7 +115,6 @@ async def async_setup_entry( AirzoneZoneSensor( coordinator, description, - entry, zone_id, zone_data, ) @@ -154,14 +145,13 @@ class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): self, coordinator: AirzoneUpdateCoordinator, description: SensorEntityDescription, - entry: ConfigEntry, aidoo_id: str, aidoo_data: dict[str, Any], ) -> None: """Initialize.""" - super().__init__(coordinator, entry, aidoo_id, aidoo_data) + super().__init__(coordinator, aidoo_id, aidoo_data) - self._attr_name = f"{aidoo_data[AZD_NAME]} {description.name}" + self._attr_has_entity_name = True self._attr_unique_id = f"{aidoo_id}_{description.key}" self.entity_description = description @@ -175,13 +165,13 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): self, coordinator: AirzoneUpdateCoordinator, description: SensorEntityDescription, - entry: ConfigEntry, ws_id: str, ws_data: dict[str, Any], ) -> None: """Initialize.""" - super().__init__(coordinator, entry, ws_id, ws_data) + super().__init__(coordinator, ws_id, ws_data) + self._attr_has_entity_name = True self._attr_unique_id = f"{ws_id}_{description.key}" self.entity_description = description @@ -195,14 +185,13 @@ class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): self, coordinator: AirzoneUpdateCoordinator, description: SensorEntityDescription, - entry: ConfigEntry, zone_id: str, zone_data: dict[str, Any], ) -> None: """Initialize.""" - super().__init__(coordinator, entry, zone_id, zone_data) + super().__init__(coordinator, zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" + self._attr_has_entity_name = True self._attr_unique_id = f"{zone_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 32eb34333c9..25d601cf299 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -40,26 +40,24 @@ class AladdinDevice(CoverEntity): _attr_device_class = CoverDeviceClass.GARAGE _attr_supported_features = SUPPORTED_FEATURES + _attr_has_entity_name = True + _attr_name = None def __init__( self, acc: AladdinConnectClient, device: DoorDevice, entry: ConfigEntry ) -> None: """Initialize the Aladdin Connect cover.""" self._acc = acc - self._entry_id = entry.entry_id self._device_id = device["device_id"] self._number = device["door_number"] - self._name = device["name"] self._serial = device["serial"] - self._model = device["model"] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._device_id}-{self._number}")}, - name=self._name, + name=device["name"], manufacturer="Overhead Door", - model=self._model, + model=device["model"], ) - self._attr_has_entity_name = True self._attr_unique_id = f"{self._device_id}-{self._number}" async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index 51ae5154302..395bbbb04a8 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -40,7 +40,6 @@ class AccSensorEntityDescription( SENSORS: tuple[AccSensorEntityDescription, ...] = ( AccSensorEntityDescription( key="battery_level", - name="Battery level", device_class=SensorDeviceClass.BATTERY, entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, @@ -49,7 +48,7 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = ( ), AccSensorEntityDescription( key="rssi", - name="Wi-Fi RSSI", + translation_key="wifi_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -58,7 +57,7 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = ( ), AccSensorEntityDescription( key="ble_strength", - name="BLE Strength", + translation_key="ble_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -89,8 +88,8 @@ async def async_setup_entry( class AladdinConnectSensor(SensorEntity): """A sensor implementation for Aladdin Connect devices.""" - _device: AladdinConnectSensor entity_description: AccSensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -101,24 +100,20 @@ class AladdinConnectSensor(SensorEntity): """Initialize a sensor for an Aladdin Connect device.""" self._device_id = device["device_id"] self._number = device["door_number"] - self._name = device["name"] - self._model = device["model"] self._acc = acc self.entity_description = description self._attr_unique_id = f"{self._device_id}-{self._number}-{description.key}" - self._attr_has_entity_name = True - if self._model == "01" and description.key in ("battery_level", "ble_strength"): - self._attr_entity_registry_enabled_default = True - - @property - def device_info(self) -> DeviceInfo | None: - """Device information for Aladdin Connect sensors.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._device_id}-{self._number}")}, - name=self._name, + name=device["name"], manufacturer="Overhead Door", - model=self._model, + model=device["model"], ) + if device["model"] == "01" and description.key in ( + "battery_level", + "ble_strength", + ): + self._attr_entity_registry_enabled_default = True @property def native_value(self) -> float | None: diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index ff42ca14bc3..bfe932b039c 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -25,5 +25,15 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "wifi_strength": { + "name": "Wi-Fi RSSI" + }, + "ble_strength": { + "name": "BLE Strength" + } + } } } diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index de4f3df257a..e453be88934 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -5,6 +5,7 @@ from typing import Final import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -44,15 +45,22 @@ ACTION_TYPES: Final[set[str]] = { "trigger", } -ACTION_SCHEMA: Final = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA: Final = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Optional(CONF_CODE): cv.string, } ) +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( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -70,7 +78,7 @@ async def async_get_actions( base_action: dict = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } # Add actions for each entity that belongs to this integration @@ -124,7 +132,9 @@ async def async_get_action_capabilities( """List action capabilities.""" # We need to refer to the state directly because ATTR_CODE_ARM_REQUIRED is not a # capability attribute - state = hass.states.get(config[CONF_ENTITY_ID]) + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[CONF_ENTITY_ID]) + state = hass.states.get(entity_id) if entity_id else None code_required = state.attributes.get(ATTR_CODE_ARM_REQUIRED) if state else False if config[CONF_TYPE] == "trigger" or ( diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index a097aa98535..ee8cb57f568 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -58,7 +58,7 @@ CONDITION_TYPES: Final[set[str]] = { CONDITION_SCHEMA: Final = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -83,7 +83,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [ @@ -126,8 +126,11 @@ def async_condition_from_config( elif config[CONF_TYPE] == CONDITION_ARMED_CUSTOM_BYPASS: state = STATE_ALARM_ARMED_CUSTOM_BYPASS + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 9106942c5e5..fc3850dce30 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -46,7 +46,7 @@ TRIGGER_TYPES: Final[set[str]] = BASIC_TRIGGER_TYPES | { TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -72,7 +72,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } triggers += [ diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json index 66de4b6a5f8..c94da6bf487 100644 --- a/homeassistant/components/alpha_vantage/manifest.json +++ b/homeassistant/components/alpha_vantage/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/alpha_vantage", "iot_class": "cloud_polling", "loggers": ["alpha_vantage"], - "requirements": ["alpha_vantage==2.3.1"] + "requirements": ["alpha-vantage==2.3.1"] } diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json index f2fd0ea5d77..315490b2d62 100644 --- a/homeassistant/components/ambiclimate/manifest.json +++ b/homeassistant/components/ambiclimate/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ambiclimate", "iot_class": "cloud_polling", "loggers": ["ambiclimate"], - "requirements": ["ambiclimate==0.2.1"] + "requirements": ["Ambiclimate==0.2.1"] } diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index ff1a283769d..306c24a94ac 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from homeassistant.helpers.typing import UndefinedType + from .const import DOMAIN @@ -14,7 +16,7 @@ def service_signal(service: str, *args: str) -> str: def log_update_error( logger: logging.Logger, action: str, - name: str | None, + name: str | UndefinedType | None, entity_type: str, error: Exception, level: int = logging.ERROR, diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 9419f00e41e..19e6b5ec7b3 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -21,12 +21,22 @@ from homeassistant.components.recorder import ( DOMAIN as RECORDER_DOMAIN, get_instance as get_recorder_instance, ) +import homeassistant.config as conf_util +from homeassistant.config_entries import ( + SOURCE_IGNORE, +) from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.helpers.system_info import async_get_system_info -from homeassistant.loader import IntegrationNotFound, async_get_integrations +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_integrations, +) from homeassistant.setup import async_get_loaded_integrations from .const import ( @@ -206,8 +216,25 @@ class Analytics: if self.preferences.get(ATTR_USAGE, False) or self.preferences.get( ATTR_STATISTICS, False ): + ent_reg = er.async_get(self.hass) + + try: + yaml_configuration = await conf_util.async_hass_config_yaml(self.hass) + except HomeAssistantError as err: + LOGGER.error(err) + return + + configuration_set = set(yaml_configuration) + er_platforms = { + entity.platform + for entity in ent_reg.entities.values() + if not entity.disabled + } + domains = async_get_loaded_integrations(self.hass) configured_integrations = await async_get_integrations(self.hass, domains) + enabled_domains = set(configured_integrations) + for integration in configured_integrations.values(): if isinstance(integration, IntegrationNotFound): continue @@ -215,7 +242,11 @@ class Analytics: if isinstance(integration, BaseException): raise integration - if integration.disabled: + if not self._async_should_report_integration( + integration=integration, + yaml_domains=configuration_set, + entity_registry_platforms=er_platforms, + ): continue if not integration.is_built_in: @@ -253,12 +284,12 @@ class Analytics: if supervisor_info is not None: payload[ATTR_ADDONS] = addons - if ENERGY_DOMAIN in integrations: + if ENERGY_DOMAIN in enabled_domains: payload[ATTR_ENERGY] = { ATTR_CONFIGURED: await energy_is_configured(self.hass) } - if RECORDER_DOMAIN in integrations: + if RECORDER_DOMAIN in enabled_domains: instance = get_recorder_instance(self.hass) engine = instance.database_engine if engine and engine.version is not None: @@ -306,3 +337,34 @@ class Analytics: LOGGER.error( "Error sending analytics to %s: %r", ANALYTICS_ENDPOINT_URL, err ) + + @callback + def _async_should_report_integration( + self, + integration: Integration, + yaml_domains: set[str], + entity_registry_platforms: set[str], + ) -> bool: + """Return a bool to indicate if this integration should be reported.""" + if integration.disabled: + return False + + # Check if the integration is defined in YAML or in the entity registry + if ( + integration.domain in yaml_domains + or integration.domain in entity_registry_platforms + ): + return True + + # Check if the integration provide a config flow + if not integration.config_flow: + return False + + entries = self.hass.config_entries.async_entries(integration.domain) + + # Filter out ignored and disabled entries + return any( + entry + for entry in entries + if entry.source != SOURCE_IGNORE and entry.disabled_by is None + ) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 563b8f07b2a..f4fbe4a498f 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -296,7 +296,6 @@ class ADBDevice(MediaPlayerEntity): self._process_config, ) ) - return @property def media_image_hash(self) -> str | None: diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index 862f317ee82..5a99805da62 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -16,6 +16,7 @@ from .const import DOMAIN class AndroidTVRemoteBaseEntity(Entity): """Android TV Remote Base Entity.""" + _attr_name = None _attr_has_entity_name = True _attr_should_poll = False diff --git a/homeassistant/components/anel_pwrctrl/manifest.json b/homeassistant/components/anel_pwrctrl/manifest.json index f1de40bc89e..48cc3b96ec0 100644 --- a/homeassistant/components/anel_pwrctrl/manifest.json +++ b/homeassistant/components/anel_pwrctrl/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/anel_pwrctrl", "iot_class": "local_polling", "loggers": ["anel_pwrctrl"], - "requirements": ["anel_pwrctrl-homeassistant==0.0.1.dev2"] + "requirements": ["anel-pwrctrl-homeassistant==0.0.1.dev2"] } diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 2ab23ff2d37..038e71750dd 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -80,6 +80,7 @@ class AnthemAVR(MediaPlayerEntity): self._attr_name = f"zone {zone_number}" self._attr_unique_id = f"{mac_address}_{zone_number}" else: + self._attr_name = None self._attr_unique_id = mac_address self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 2d2df955441..4ead41e86e9 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.12.0"], + "requirements": ["pyatv==0.13.2"], "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 4196dd1bd9a..a70a30656f2 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -282,7 +282,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Send the play_media command to the media player.""" # If input (file) has a file format supported by pyatv, then stream it with # RAOP. Otherwise try to play it with regular AirPlay. - if media_type == MediaType.APP: + if media_type in {MediaType.APP, MediaType.URL}: await self.atv.apps.launch_app(media_id) return diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index 1420c0ffefc..e5948a54a8d 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -49,6 +49,7 @@ }, "abort": { "ipv6_not_supported": "IPv6 is not supported.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "device_did_not_pair": "No attempt to finish pairing process was made from the device.", diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json index 1bac2bdfb5f..011b8e67a19 100644 --- a/homeassistant/components/aquostv/manifest.json +++ b/homeassistant/components/aquostv/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/aquostv", "iot_class": "local_polling", "loggers": ["sharp_aquos_rc"], - "requirements": ["sharp_aquos_rc==0.3.2"] + "requirements": ["sharp-aquos-rc==0.3.2"] } diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index 6ac27b1652b..5c223940915 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -1,6 +1,8 @@ """Support for Aranet sensors.""" from __future__ import annotations +from dataclasses import dataclass + from aranet4.client import Aranet4Advertisement from bleak.backends.device import BLEDevice @@ -23,6 +25,7 @@ from homeassistant.const import ( ATTR_SW_VERSION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + EntityCategory, UnitOfPressure, UnitOfTemperature, UnitOfTime, @@ -33,43 +36,55 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN + +@dataclass +class AranetSensorEntityDescription(SensorEntityDescription): + """Class to describe an Aranet sensor entity.""" + + # PassiveBluetoothDataUpdate does not support UNDEFINED + # Restrict the type to satisfy the type checker and catch attempts + # to use UNDEFINED in the entity descriptions. + name: str | None = None + + SENSOR_DESCRIPTIONS = { - "temperature": SensorEntityDescription( + "temperature": AranetSensorEntityDescription( key="temperature", name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), - "humidity": SensorEntityDescription( + "humidity": AranetSensorEntityDescription( key="humidity", name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - "pressure": SensorEntityDescription( + "pressure": AranetSensorEntityDescription( key="pressure", name="Pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, ), - "co2": SensorEntityDescription( + "co2": AranetSensorEntityDescription( key="co2", name="Carbon Dioxide", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ), - "battery": SensorEntityDescription( + "battery": AranetSensorEntityDescription( key="battery", name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), - "interval": SensorEntityDescription( + "interval": AranetSensorEntityDescription( key="update_interval", name="Update Interval", device_class=SensorDeviceClass.DURATION, @@ -77,6 +92,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, # The interval setting is not a generally useful entity for most users. entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index ecaec0e0e7d..ef83217ee26 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -3,7 +3,9 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, +) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -22,7 +24,7 @@ from .const import DOMAIN, EVENT_TURN_ON TRIGGER_TYPES = {"turn_on"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), } ) @@ -43,7 +45,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "turn_on", } ) @@ -62,7 +64,8 @@ async def async_attach_trigger( job = HassJob(action) if config[CONF_TYPE] == "turn_on": - entity_id = config[CONF_ENTITY_ID] + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) @callback def _handle_event(event: Event) -> None: @@ -71,9 +74,10 @@ async def async_attach_trigger( job, { "trigger": { - **trigger_data, # type: ignore[arg-type] # https://github.com/python/mypy/issues/9117 + **trigger_data, **config, "description": f"{DOMAIN} - {entity_id}", + "entity_id": entity_id, } }, event.context, diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 9a76d4843f0..2c9b64b00ce 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.3.0"], + "requirements": ["arcam-fmj==1.4.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 9e460464cb9..55b192a730a 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -57,6 +57,7 @@ async def async_pipeline_from_audio_stream( pipeline_id: str | None = None, conversation_id: str | None = None, tts_audio_output: str | None = None, + device_id: str | None = None, ) -> None: """Create an audio pipeline from an audio stream. @@ -64,6 +65,7 @@ async def async_pipeline_from_audio_stream( """ pipeline_input = PipelineInput( conversation_id=conversation_id, + device_id=device_id, stt_metadata=stt_metadata, stt_stream=stt_stream, run=PipelineRun( diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 12764c04f04..891fc639fee 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -499,7 +499,7 @@ class PipelineRun: self.intent_agent = agent_info.id async def recognize_intent( - self, intent_input: str, conversation_id: str | None + self, intent_input: str, conversation_id: str | None, device_id: str | None ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" if self.intent_agent is None: @@ -512,6 +512,8 @@ class PipelineRun: "engine": self.intent_agent, "language": self.pipeline.conversation_language, "intent_input": intent_input, + "conversation_id": conversation_id, + "device_id": device_id, }, ) ) @@ -521,6 +523,7 @@ class PipelineRun: hass=self.hass, text=intent_input, conversation_id=conversation_id, + device_id=device_id, context=self.context, language=self.pipeline.conversation_language, agent_id=self.intent_agent, @@ -655,6 +658,8 @@ class PipelineInput: conversation_id: str | None = None + device_id: str | None = None + async def execute(self) -> None: """Run pipeline.""" self.run.start() @@ -678,7 +683,9 @@ class PipelineInput: if current_stage == PipelineStage.INTENT: assert intent_input is not None tts_input = await self.run.recognize_intent( - intent_input, self.conversation_id + intent_input, + self.conversation_id, + self.device_id, ) current_stage = PipelineStage.TTS @@ -730,17 +737,30 @@ class PipelineInput: ) start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) + end_stage_index = PIPELINE_STAGE_ORDER.index(self.run.end_stage) prepare_tasks = [] - if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT): + if ( + start_stage_index + <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT) + <= end_stage_index + ): # self.stt_metadata can't be None or we'd raise above prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) # type: ignore[arg-type] - if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT): + if ( + start_stage_index + <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT) + <= end_stage_index + ): prepare_tasks.append(self.run.prepare_recognize_intent()) - if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS): + if ( + start_stage_index + <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS) + <= end_stage_index + ): prepare_tasks.append(self.run.prepare_text_to_speech()) if prepare_tasks: @@ -944,6 +964,7 @@ class PipelineData: pipeline_runs: dict[str, LimitedSizeDict[str, PipelineRunDebug]] pipeline_store: PipelineStorageCollection + pipeline_devices: set[str] = field(default_factory=set, init=False) @dataclass diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 9ac1d6b5888..2ae46fcb9ac 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -10,7 +10,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, entity_registry as er, restore_state from .const import DOMAIN -from .pipeline import PipelineStorageCollection +from .pipeline import PipelineData, PipelineStorageCollection +from .vad import VadSensitivity OPTION_PREFERRED = "preferred" @@ -38,6 +39,25 @@ def get_chosen_pipeline( ) +@callback +def get_vad_sensitivity( + hass: HomeAssistant, domain: str, unique_id_prefix: str +) -> VadSensitivity: + """Get the chosen vad sensitivity for a domain.""" + ent_reg = er.async_get(hass) + sensitivity_entity_id = ent_reg.async_get_entity_id( + Platform.SELECT, domain, f"{unique_id_prefix}-vad_sensitivity" + ) + if sensitivity_entity_id is None: + return VadSensitivity.DEFAULT + + state = hass.states.get(sensitivity_entity_id) + if state is None: + return VadSensitivity.DEFAULT + + return VadSensitivity(state.state) + + class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): """Entity to represent a pipeline selector.""" @@ -60,15 +80,24 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): """When entity is added to Home Assistant.""" await super().async_added_to_hass() - pipeline_store: PipelineStorageCollection = self.hass.data[ - DOMAIN - ].pipeline_store - pipeline_store.async_add_change_set_listener(self._pipelines_updated) + pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_store = pipeline_data.pipeline_store + self.async_on_remove( + pipeline_store.async_add_change_set_listener(self._pipelines_updated) + ) state = await self.async_get_last_state() if state is not None and state.state in self.options: self._attr_current_option = state.state + if self.registry_entry and (device_id := self.registry_entry.device_id): + pipeline_data.pipeline_devices.add(device_id) + self.async_on_remove( + lambda: pipeline_data.pipeline_devices.discard( + device_id # type: ignore[arg-type] + ) + ) + async def async_select_option(self, option: str) -> None: """Select an option.""" self._attr_current_option = option @@ -93,3 +122,34 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): if self._attr_current_option not in options: self._attr_current_option = OPTION_PREFERRED + + +class VadSensitivitySelect(SelectEntity, restore_state.RestoreEntity): + """Entity to represent VAD sensitivity.""" + + entity_description = SelectEntityDescription( + key="vad_sensitivity", + translation_key="vad_sensitivity", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = VadSensitivity.DEFAULT.value + _attr_options = [vs.value for vs in VadSensitivity] + + def __init__(self, hass: HomeAssistant, unique_id_prefix: str) -> None: + """Initialize a pipeline selector.""" + self._attr_unique_id = f"{unique_id_prefix}-vad_sensitivity" + self.hass = hass + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json index d85eb1aaed9..8fa67879fc3 100644 --- a/homeassistant/components/assist_pipeline/strings.json +++ b/homeassistant/components/assist_pipeline/strings.json @@ -11,6 +11,14 @@ "state": { "preferred": "Preferred" } + }, + "vad_sensitivity": { + "name": "Finished speaking detection", + "state": { + "default": "Default", + "aggressive": "Aggressive", + "relaxed": "Relaxed" + } } } } diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index c5f87f1336a..a737490f22f 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -1,11 +1,35 @@ """Voice activity detection.""" +from __future__ import annotations + from dataclasses import dataclass, field import webrtcvad +from homeassistant.backports.enum import StrEnum + _SAMPLE_RATE = 16000 +class VadSensitivity(StrEnum): + """How quickly the end of a voice command is detected.""" + + DEFAULT = "default" + RELAXED = "relaxed" + AGGRESSIVE = "aggressive" + + @staticmethod + def to_seconds(sensitivity: VadSensitivity | str) -> float: + """Return seconds of silence for sensitivity level.""" + sensitivity = VadSensitivity(sensitivity) + if sensitivity == VadSensitivity.RELAXED: + return 2.0 + + if sensitivity == VadSensitivity.AGGRESSIVE: + return 0.5 + + return 1.0 + + @dataclass class VoiceCommandSegmenter: """Segments an audio stream into voice commands using webrtcvad.""" @@ -113,16 +137,15 @@ class VoiceCommandSegmenter: self._reset_seconds_left -= self._seconds_per_chunk if self._reset_seconds_left <= 0: self._speech_seconds_left = self.speech_seconds + elif not is_speech: + self._reset_seconds_left = self.reset_seconds + self._silence_seconds_left -= self._seconds_per_chunk + if self._silence_seconds_left <= 0: + return False else: - if not is_speech: - self._reset_seconds_left = self.reset_seconds - self._silence_seconds_left -= self._seconds_per_chunk - if self._silence_seconds_left <= 0: - return False - else: - # Reset if enough speech - self._reset_seconds_left -= self._seconds_per_chunk - if self._reset_seconds_left <= 0: - self._silence_seconds_left = self.silence_seconds + # Reset if enough speech + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._silence_seconds_left = self.silence_seconds return True diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 3d8a07dc0b3..ea3aacf43a4 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -56,6 +56,7 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: vol.Optional("input"): dict, vol.Optional("pipeline"): str, vol.Optional("conversation_id"): vol.Any(str, None), + vol.Optional("device_id"): vol.Any(str, None), vol.Optional("timeout"): vol.Any(float, int), }, ), @@ -105,6 +106,7 @@ async def websocket_run( # Arguments to PipelineInput input_args: dict[str, Any] = { "conversation_id": msg.get("conversation_id"), + "device_id": msg.get("device_id"), } if start_stage == PipelineStage.STT: @@ -280,7 +282,6 @@ def websocket_get_run( ) -@callback @websocket_api.websocket_command( { vol.Required("type"): "assist_pipeline/language/list", diff --git a/homeassistant/components/asterisk_mbox/manifest.json b/homeassistant/components/asterisk_mbox/manifest.json index 8348e40ba6b..840c48aff2a 100644 --- a/homeassistant/components/asterisk_mbox/manifest.json +++ b/homeassistant/components/asterisk_mbox/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/asterisk_mbox", "iot_class": "local_push", "loggers": ["asterisk_mbox"], - "requirements": ["asterisk_mbox==0.5.0"] + "requirements": ["asterisk-mbox==0.5.0"] } diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index ce52bd4fd65..9b2729f141e 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.enum import try_parse_enum from . import DOMAIN, AtagEntity @@ -52,14 +53,12 @@ class AtagThermostat(AtagEntity, ClimateEntity): self._attr_temperature_unit = coordinator.data.climate.temp_unit @property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" - if self.coordinator.data.climate.hvac_mode in HVAC_MODES: - return self.coordinator.data.climate.hvac_mode - return None + return try_parse_enum(HVACMode, self.coordinator.data.climate.hvac_mode) @property - def hvac_action(self) -> str | None: + def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation.""" is_active = self.coordinator.data.climate.status return HVACAction.HEATING if is_active else HVACAction.IDLE diff --git a/homeassistant/components/atome/manifest.json b/homeassistant/components/atome/manifest.json index 236bf6cb082..cafe24e2e13 100644 --- a/homeassistant/components/atome/manifest.json +++ b/homeassistant/components/atome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/atome", "iot_class": "cloud_polling", "loggers": ["pyatome"], - "requirements": ["pyatome==0.1.1"] + "requirements": ["pyAtome==0.1.1"] } diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index d380ee11834..c6f406a5094 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -5,7 +5,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import cast from yalexs.activity import ( ACTION_DOORBELL_CALL_MISSED, @@ -104,7 +103,16 @@ def _native_datetime() -> datetime: @dataclass -class AugustRequiredKeysMixin: +class AugustBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes August binary_sensor entity.""" + + # AugustBinarySensor does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + + +@dataclass +class AugustDoorbellRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[AugustData, DoorbellDetail], bool] @@ -112,41 +120,45 @@ class AugustRequiredKeysMixin: @dataclass -class AugustBinarySensorEntityDescription( - BinarySensorEntityDescription, AugustRequiredKeysMixin +class AugustDoorbellBinarySensorEntityDescription( + BinarySensorEntityDescription, AugustDoorbellRequiredKeysMixin ): """Describes August binary_sensor entity.""" + # AugustDoorbellBinarySensor does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" -SENSOR_TYPE_DOOR = BinarySensorEntityDescription( + +SENSOR_TYPE_DOOR = AugustBinarySensorEntityDescription( key="door_open", name="Open", ) -SENSOR_TYPES_DOORBELL: tuple[AugustBinarySensorEntityDescription, ...] = ( - AugustBinarySensorEntityDescription( +SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = ( + AugustDoorbellBinarySensorEntityDescription( key="doorbell_ding", name="Ding", device_class=BinarySensorDeviceClass.OCCUPANCY, value_fn=_retrieve_ding_state, is_time_based=True, ), - AugustBinarySensorEntityDescription( + AugustDoorbellBinarySensorEntityDescription( key="doorbell_motion", name="Motion", device_class=BinarySensorDeviceClass.MOTION, value_fn=_retrieve_motion_state, is_time_based=True, ), - AugustBinarySensorEntityDescription( + AugustDoorbellBinarySensorEntityDescription( key="doorbell_image_capture", name="Image Capture", icon="mdi:file-image", value_fn=_retrieve_image_capture_state, is_time_based=True, ), - AugustBinarySensorEntityDescription( + AugustDoorbellBinarySensorEntityDescription( key="doorbell_online", name="Online", device_class=BinarySensorDeviceClass.CONNECTIVITY, @@ -199,7 +211,10 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.DOOR def __init__( - self, data: AugustData, device: Lock, description: BinarySensorEntityDescription + self, + data: AugustData, + device: Lock, + description: AugustBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(data, device) @@ -207,9 +222,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): self._data = data self._device = device self._attr_name = f"{device.device_name} {description.name}" - self._attr_unique_id = ( - f"{self._device_id}_{cast(str, description.name).lower()}" - ) + self._attr_unique_id = f"{self._device_id}_{description.name.lower()}" @callback def _update_from_data(self): @@ -243,13 +256,13 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Representation of an August binary sensor.""" - entity_description: AugustBinarySensorEntityDescription + entity_description: AugustDoorbellBinarySensorEntityDescription def __init__( self, data: AugustData, device: Doorbell, - description: AugustBinarySensorEntityDescription, + description: AugustDoorbellBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(data, device) @@ -257,9 +270,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): self._check_for_off_update_listener = None self._data = data self._attr_name = f"{device.device_name} {description.name}" - self._attr_unique_id = ( - f"{self._device_id}_{cast(str, description.name).lower()}" - ) + self._attr_unique_id = f"{self._device_id}_{description.name.lower()}" @callback def _update_from_data(self): diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index bac402fe633..db054910d9a 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -1,25 +1,15 @@ """The aurora component.""" -from datetime import timedelta import logging -from aiohttp import ClientError from auroranoaa import AuroraForecast from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) from .const import ( - ATTRIBUTION, AURORA_API, CONF_THRESHOLD, COORDINATOR, @@ -27,6 +17,7 @@ from .const import ( DEFAULT_THRESHOLD, DOMAIN, ) +from .coordinator import AuroraDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -79,71 +70,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AuroraDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the NOAA Aurora API.""" - - def __init__( - self, - hass: HomeAssistant, - name: str, - polling_interval: int, - api: str, - latitude: float, - longitude: float, - threshold: float, - ) -> None: - """Initialize the data updater.""" - - super().__init__( - hass=hass, - logger=_LOGGER, - name=name, - update_interval=timedelta(minutes=polling_interval), - ) - - self.api = api - self.name = name - self.latitude = int(latitude) - self.longitude = int(longitude) - self.threshold = int(threshold) - - async def _async_update_data(self): - """Fetch the data from the NOAA Aurora Forecast.""" - - try: - return await self.api.get_forecast_data(self.longitude, self.latitude) - except ClientError as error: - raise UpdateFailed(f"Error updating from NOAA: {error}") from error - - -class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): - """Implementation of the base Aurora Entity.""" - - _attr_attribution = ATTRIBUTION - - def __init__( - self, - coordinator: AuroraDataUpdateCoordinator, - name: str, - icon: str, - ) -> None: - """Initialize the Aurora Entity.""" - - super().__init__(coordinator=coordinator) - - self._attr_name = name - self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" - self._attr_icon = icon - - @property - def device_info(self) -> DeviceInfo: - """Define the device based on name.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(self.unique_id))}, - manufacturer="NOAA", - model="Aurora Visibility Sensor", - name=self.coordinator.name, - ) diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index ee2fc53691e..a0e09685a0f 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -4,8 +4,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AuroraEntity from .const import COORDINATOR, DOMAIN +from .entity import AuroraEntity async def async_setup_entry( diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py new file mode 100644 index 00000000000..c126e2a8c68 --- /dev/null +++ b/homeassistant/components/aurora/coordinator.py @@ -0,0 +1,52 @@ +"""The aurora component.""" + +from datetime import timedelta +import logging + +from aiohttp import ClientError +from auroranoaa import AuroraForecast + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) + +_LOGGER = logging.getLogger(__name__) + + +class AuroraDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the NOAA Aurora API.""" + + def __init__( + self, + hass: HomeAssistant, + name: str, + polling_interval: int, + api: AuroraForecast, + latitude: float, + longitude: float, + threshold: float, + ) -> None: + """Initialize the data updater.""" + + super().__init__( + hass=hass, + logger=_LOGGER, + name=name, + update_interval=timedelta(minutes=polling_interval), + ) + + self.api = api + self.name = name + self.latitude = int(latitude) + self.longitude = int(longitude) + self.threshold = int(threshold) + + async def _async_update_data(self): + """Fetch the data from the NOAA Aurora Forecast.""" + + try: + return await self.api.get_forecast_data(self.longitude, self.latitude) + except ClientError as error: + raise UpdateFailed(f"Error updating from NOAA: {error}") from error diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py new file mode 100644 index 00000000000..8948ff9c43c --- /dev/null +++ b/homeassistant/components/aurora/entity.py @@ -0,0 +1,48 @@ +"""The aurora component.""" + +import logging + +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) + +from .const import ( + ATTRIBUTION, + DOMAIN, +) +from .coordinator import AuroraDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): + """Implementation of the base Aurora Entity.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: AuroraDataUpdateCoordinator, + name: str, + icon: str, + ) -> None: + """Initialize the Aurora Entity.""" + + super().__init__(coordinator=coordinator) + + self._attr_name = name + self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" + self._attr_icon = icon + + @property + def device_info(self) -> DeviceInfo: + """Define the device based on name.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(self.unique_id))}, + manufacturer="NOAA", + model="Aurora Visibility Sensor", + name=self.coordinator.name, + ) diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index de5e566e268..a5436e1e219 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -5,8 +5,8 @@ from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AuroraEntity from .const import COORDINATOR, DOMAIN +from .entity import AuroraEntity async def async_setup_entry( diff --git a/homeassistant/components/aurora_abb_powerone/aurora_device.py b/homeassistant/components/aurora_abb_powerone/aurora_device.py index 5a524851bdf..6d3260a45f4 100644 --- a/homeassistant/components/aurora_abb_powerone/aurora_device.py +++ b/homeassistant/components/aurora_abb_powerone/aurora_device.py @@ -47,10 +47,10 @@ class AuroraEntity(Entity): @property def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - "identifiers": {(DOMAIN, self._data[ATTR_SERIAL_NUMBER])}, - "manufacturer": MANUFACTURER, - "model": self._data[ATTR_MODEL], - "name": self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME), - "sw_version": self._data[ATTR_FIRMWARE], - } + return DeviceInfo( + identifiers={(DOMAIN, self._data[ATTR_SERIAL_NUMBER])}, + manufacturer=MANUFACTURER, + model=self._data[ATTR_MODEL], + name=self._data.get(ATTR_DEVICE_NAME, DEFAULT_DEVICE_NAME), + sw_version=self._data[ATTR_FIRMWARE], + ) diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 27a8a65c27f..55f3be5d6db 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -34,7 +34,7 @@ SENSOR_TYPES = [ device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, - name="Power Output", + translation_key="power_output", ), SensorEntityDescription( key="temp", @@ -42,14 +42,13 @@ SENSOR_TYPES = [ entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - name="Temperature", ), SensorEntityDescription( key="totalenergy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - name="Total Energy", + translation_key="total_energy", ), ] @@ -75,6 +74,8 @@ async def async_setup_entry( class AuroraSensor(AuroraEntity, SensorEntity): """Representation of a Sensor on a Aurora ABB PowerOne Solar inverter.""" + _attr_has_entity_name = True + def __init__( self, client: AuroraSerialClient, diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index bed403bd641..50b6e0db502 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -18,5 +18,15 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_serial_ports": "No com ports found. Need a valid RS485 device to communicate." } + }, + "entity": { + "sensor": { + "power_output": { + "name": "Power Output" + }, + "total_energy": { + "name": "Total Energy" + } + } } } diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index 1ed146b6237..fa407949b40 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -35,7 +35,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( # Internet Services sensors SensorValueEntityDescription( key="usedMb", - name="Data used", + translation_key="data_used", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -43,7 +43,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="downloadedMb", - name="Downloaded", + translation_key="downloaded", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -51,7 +51,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="uploadedMb", - name="Uploaded", + translation_key="uploaded", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -60,21 +60,21 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( # Mobile Phone Services sensors SensorValueEntityDescription( key="national", - name="National calls", + translation_key="national_calls", state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="mobile", - name="Mobile calls", + translation_key="mobile_calls", state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="international", - name="International calls", + translation_key="international_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone-plus", @@ -82,14 +82,14 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="sms", - name="SMS sent", + translation_key="sms_sent", state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:message-processing", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="internet", - name="Data used", + translation_key="data_used", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.KILOBYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -98,7 +98,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="voicemail", - name="Voicemail calls", + translation_key="voicemail_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", @@ -106,7 +106,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( ), SensorValueEntityDescription( key="other", - name="Other calls", + translation_key="other_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", @@ -115,13 +115,13 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( # Generic sensors SensorValueEntityDescription( key="daysTotal", - name="Billing cycle length", + translation_key="billing_cycle_length", native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:calendar-range", ), SensorValueEntityDescription( key="daysRemaining", - name="Billing cycle remaining", + translation_key="billing_cycle_remaining", native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:calendar-clock", ), diff --git a/homeassistant/components/aussie_broadband/strings.json b/homeassistant/components/aussie_broadband/strings.json index c2052defa81..90e4f094ee6 100644 --- a/homeassistant/components/aussie_broadband/strings.json +++ b/homeassistant/components/aussie_broadband/strings.json @@ -46,5 +46,42 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "data_used": { + "name": "Data used" + }, + "downloaded": { + "name": "Downloaded" + }, + "uploaded": { + "name": "Uploaded" + }, + "national_calls": { + "name": "National calls" + }, + "mobile_calls": { + "name": "Mobile calls" + }, + "international_calls": { + "name": "International calls" + }, + "sms_sent": { + "name": "SMS sent" + }, + "voicemail_calls": { + "name": "Voicemail calls" + }, + "other_calls": { + "name": "Other calls" + }, + "billing_cycle_length": { + "name": "Billing cycle length" + }, + "billing_cycle_remaining": { + "name": "Billing cycle remaining" + } + } } } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 600cc6013e4..f4db7831235 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,6 +1,7 @@ """Allow to set up simple automation rules via the config file.""" from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from collections.abc import Callable, Mapping from dataclasses import dataclass @@ -153,7 +154,7 @@ def _automations_with_x( if DOMAIN not in hass.data: return [] - component: EntityComponent[AutomationEntity] = hass.data[DOMAIN] + component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] return [ automation_entity.entity_id @@ -169,7 +170,7 @@ def _x_in_automation( if DOMAIN not in hass.data: return [] - component: EntityComponent[AutomationEntity] = hass.data[DOMAIN] + component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] if (automation_entity := component.get_entity(entity_id)) is None: return [] @@ -219,7 +220,7 @@ def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list if DOMAIN not in hass.data: return [] - component: EntityComponent[AutomationEntity] = hass.data[DOMAIN] + component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] return [ automation_entity.entity_id @@ -234,7 +235,7 @@ def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None: if DOMAIN not in hass.data: return None - component: EntityComponent[AutomationEntity] = hass.data[DOMAIN] + component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] if (automation_entity := component.get_entity(entity_id)) is None: return None @@ -244,7 +245,7 @@ def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up all automations.""" - hass.data[DOMAIN] = component = EntityComponent[AutomationEntity]( + hass.data[DOMAIN] = component = EntityComponent[BaseAutomationEntity]( LOGGER, DOMAIN, hass ) @@ -262,7 +263,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_get_blueprints(hass).async_populate() async def trigger_service_handler( - entity: AutomationEntity, service_call: ServiceCall + entity: BaseAutomationEntity, service_call: ServiceCall ) -> None: """Handle forced automation trigger, e.g. from frontend.""" await entity.async_trigger( @@ -310,7 +311,103 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class AutomationEntity(ToggleEntity, RestoreEntity): +class BaseAutomationEntity(ToggleEntity, ABC): + """Base class for automation entities.""" + + raw_config: ConfigType | None + + @property + def capability_attributes(self) -> dict[str, Any] | None: + """Return capability attributes.""" + if self.unique_id is not None: + return {CONF_ID: self.unique_id} + return None + + @property + @abstractmethod + def referenced_areas(self) -> set[str]: + """Return a set of referenced areas.""" + + @property + @abstractmethod + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + + @property + @abstractmethod + def referenced_devices(self) -> set[str]: + """Return a set of referenced devices.""" + + @property + @abstractmethod + def referenced_entities(self) -> set[str]: + """Return a set of referenced entities.""" + + @abstractmethod + async def async_trigger( + self, + run_variables: dict[str, Any], + context: Context | None = None, + skip_condition: bool = False, + ) -> None: + """Trigger automation.""" + + +class UnavailableAutomationEntity(BaseAutomationEntity): + """A non-functional automation entity with its state set to unavailable. + + This class is instatiated when an automation fails to validate. + """ + + _attr_should_poll = False + _attr_available = False + + def __init__( + self, + automation_id: str | None, + name: str, + raw_config: ConfigType | None, + ) -> None: + """Initialize an automation entity.""" + self._name = name + self._attr_unique_id = automation_id + self.raw_config = raw_config + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def referenced_areas(self) -> set[str]: + """Return a set of referenced areas.""" + return set() + + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + return None + + @property + def referenced_devices(self) -> set[str]: + """Return a set of referenced devices.""" + return set() + + @property + def referenced_entities(self) -> set[str]: + """Return a set of referenced entities.""" + return set() + + async def async_trigger( + self, + run_variables: dict[str, Any], + context: Context | None = None, + skip_condition: bool = False, + ) -> None: + """Trigger automation.""" + + +class AutomationEntity(BaseAutomationEntity, RestoreEntity): """Entity to show status of entity.""" _attr_should_poll = False @@ -330,7 +427,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): trace_config: ConfigType, ) -> None: """Initialize an automation entity.""" - self._attr_name = name + self._name = name self._trigger_config = trigger_config self._async_detach_triggers: CALLBACK_TYPE | None = None self._cond_func = cond_func @@ -348,6 +445,11 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._trace_config = trace_config self._attr_unique_id = automation_id + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + @property def extra_state_attributes(self) -> dict[str, Any]: """Return the entity state attributes.""" @@ -358,8 +460,6 @@ class AutomationEntity(ToggleEntity, RestoreEntity): } if self.action_script.supports_max: attrs[ATTR_MAX] = self.action_script.max_runs - if self.unique_id is not None: - attrs[CONF_ID] = self.unique_id return attrs @property @@ -681,6 +781,7 @@ class AutomationEntityConfig: list_no: int raw_blueprint_inputs: ConfigType | None raw_config: ConfigType | None + validation_failed: bool async def _prepare_automation_config( @@ -695,9 +796,14 @@ async def _prepare_automation_config( for list_no, config_block in enumerate(conf): raw_config = cast(AutomationConfig, config_block).raw_config raw_blueprint_inputs = cast(AutomationConfig, config_block).raw_blueprint_inputs + validation_failed = cast(AutomationConfig, config_block).validation_failed automation_configs.append( AutomationEntityConfig( - config_block, list_no, raw_blueprint_inputs, raw_config + config_block, + list_no, + raw_blueprint_inputs, + raw_config, + validation_failed, ) ) @@ -713,9 +819,9 @@ def _automation_name(automation_config: AutomationEntityConfig) -> str: async def _create_automation_entities( hass: HomeAssistant, automation_configs: list[AutomationEntityConfig] -) -> list[AutomationEntity]: +) -> list[BaseAutomationEntity]: """Create automation entities from prepared configuration.""" - entities: list[AutomationEntity] = [] + entities: list[BaseAutomationEntity] = [] for automation_config in automation_configs: config_block = automation_config.config_block @@ -723,6 +829,16 @@ async def _create_automation_entities( automation_id: str | None = config_block.get(CONF_ID) name = _automation_name(automation_config) + if automation_config.validation_failed: + entities.append( + UnavailableAutomationEntity( + automation_id, + name, + automation_config.raw_config, + ) + ) + continue + initial_state: bool | None = config_block.get(CONF_INITIAL_STATE) action_script = Script( @@ -781,18 +897,18 @@ async def _create_automation_entities( async def _async_process_config( hass: HomeAssistant, config: dict[str, Any], - component: EntityComponent[AutomationEntity], + component: EntityComponent[BaseAutomationEntity], ) -> None: """Process config and add automations.""" def automation_matches_config( - automation: AutomationEntity, config: AutomationEntityConfig + automation: BaseAutomationEntity, config: AutomationEntityConfig ) -> bool: name = _automation_name(config) return automation.name == name and automation.raw_config == config.raw_config def find_matches( - automations: list[AutomationEntity], + automations: list[BaseAutomationEntity], automation_configs: list[AutomationEntityConfig], ) -> tuple[set[int], set[int]]: """Find matches between a list of automation entities and a list of configurations. @@ -838,7 +954,7 @@ async def _async_process_config( return automation_matches, config_matches automation_configs = await _prepare_automation_config(hass, config) - automations: list[AutomationEntity] = list(component.entities) + automations: list[BaseAutomationEntity] = list(component.entities) # Find automations and configurations which have matches automation_matches, config_matches = find_matches(automations, automation_configs) @@ -860,8 +976,6 @@ async def _async_process_config( entities = await _create_automation_entities(hass, updated_automation_configs) await component.async_add_entities(entities) - return - async def _async_process_if( hass: HomeAssistant, name: str, config: dict[str, Any] @@ -965,7 +1079,7 @@ def websocket_config( msg: dict[str, Any], ) -> None: """Get automation config.""" - component: EntityComponent[AutomationEntity] = hass.data[DOMAIN] + component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] automation = component.get_entity(msg["entity_id"]) diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index c127208377f..ed801772e6d 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -41,7 +41,15 @@ from .helpers import async_get_blueprints PACKAGE_MERGE_HINT = "list" -_CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) +_MINIMAL_PLATFORM_SCHEMA = vol.Schema( + { + CONF_ID: str, + CONF_ALIAS: cv.string, + vol.Optional(CONF_DESCRIPTION): cv.string, + }, + extra=vol.ALLOW_EXTRA, +) + PLATFORM_SCHEMA = vol.All( cv.deprecated(CONF_HIDE_ENTITY), @@ -55,7 +63,7 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_INITIAL_STATE): cv.boolean, vol.Optional(CONF_HIDE_ENTITY): cv.boolean, vol.Required(CONF_TRIGGER): cv.TRIGGER_SCHEMA, - vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, + vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, vol.Optional(CONF_TRIGGER_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, @@ -68,6 +76,7 @@ PLATFORM_SCHEMA = vol.All( async def _async_validate_config_item( hass: HomeAssistant, config: ConfigType, + raise_on_errors: bool, warn_on_errors: bool, ) -> AutomationConfig: """Validate config item.""" @@ -104,6 +113,15 @@ async def _async_validate_config_item( ) return + def _minimal_config() -> AutomationConfig: + """Try validating id, alias and description.""" + minimal_config = _MINIMAL_PLATFORM_SCHEMA(config) + automation_config = AutomationConfig(minimal_config) + automation_config.raw_blueprint_inputs = raw_blueprint_inputs + automation_config.raw_config = raw_config + automation_config.validation_failed = True + return automation_config + if blueprint.is_blueprint_instance_config(config): uses_blueprint = True blueprints = async_get_blueprints(hass) @@ -115,7 +133,9 @@ async def _async_validate_config_item( "Failed to generate automation from blueprint: %s", err, ) - raise + if raise_on_errors: + raise + return _minimal_config() raw_blueprint_inputs = blueprint_inputs.config_with_inputs @@ -130,7 +150,9 @@ async def _async_validate_config_item( blueprint_inputs.inputs, err, ) - raise HomeAssistantError from err + if raise_on_errors: + raise HomeAssistantError(err) from err + return _minimal_config() automation_name = "Unnamed automation" if isinstance(config, Mapping): @@ -143,10 +165,16 @@ async def _async_validate_config_item( validated_config = PLATFORM_SCHEMA(config) except vol.Invalid as err: _log_invalid_automation(err, automation_name, "could not be validated", config) - raise + if raise_on_errors: + raise + return _minimal_config() + + automation_config = AutomationConfig(validated_config) + automation_config.raw_blueprint_inputs = raw_blueprint_inputs + automation_config.raw_config = raw_config try: - validated_config[CONF_TRIGGER] = await async_validate_trigger_config( + automation_config[CONF_TRIGGER] = await async_validate_trigger_config( hass, validated_config[CONF_TRIGGER] ) except ( @@ -156,11 +184,14 @@ async def _async_validate_config_item( _log_invalid_automation( err, automation_name, "failed to setup triggers", validated_config ) - raise + if raise_on_errors: + raise + automation_config.validation_failed = True + return automation_config if CONF_CONDITION in validated_config: try: - validated_config[CONF_CONDITION] = await async_validate_conditions_config( + automation_config[CONF_CONDITION] = await async_validate_conditions_config( hass, validated_config[CONF_CONDITION] ) except ( @@ -170,10 +201,13 @@ async def _async_validate_config_item( _log_invalid_automation( err, automation_name, "failed to setup conditions", validated_config ) - raise + if raise_on_errors: + raise + automation_config.validation_failed = True + return automation_config try: - validated_config[CONF_ACTION] = await script.async_validate_actions_config( + automation_config[CONF_ACTION] = await script.async_validate_actions_config( hass, validated_config[CONF_ACTION] ) except ( @@ -183,11 +217,11 @@ async def _async_validate_config_item( _log_invalid_automation( err, automation_name, "failed to setup actions", validated_config ) - raise + if raise_on_errors: + raise + automation_config.validation_failed = True + return automation_config - automation_config = AutomationConfig(validated_config) - automation_config.raw_blueprint_inputs = raw_blueprint_inputs - automation_config.raw_config = raw_config return automation_config @@ -196,6 +230,7 @@ class AutomationConfig(dict): raw_config: dict[str, Any] | None = None raw_blueprint_inputs: dict[str, Any] | None = None + validation_failed: bool = False async def _try_async_validate_config_item( @@ -204,7 +239,7 @@ async def _try_async_validate_config_item( ) -> AutomationConfig | None: """Validate config item.""" try: - return await _async_validate_config_item(hass, config, True) + return await _async_validate_config_item(hass, config, False, True) except (vol.Invalid, HomeAssistantError): return None @@ -215,7 +250,7 @@ async def async_validate_config_item( config: dict[str, Any], ) -> AutomationConfig | None: """Validate config item, called by EditAutomationConfigView.""" - return await _async_validate_config_item(hass, config, False) + return await _async_validate_config_item(hass, config, True, False) async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType: diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index cef2c7d1fd4..dca885ffe0d 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -2,16 +2,22 @@ from __future__ import annotations from asyncio import gather +from dataclasses import dataclass from datetime import timedelta from aiohttp import ClientSession from async_timeout import timeout from python_awair import Awair, AwairLocal +from python_awair.air_data import AirData from python_awair.devices import AwairBaseDevice, AwairLocalDevice from python_awair.exceptions import AuthError, AwairError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -23,7 +29,6 @@ from .const import ( LOGGER, UPDATE_INTERVAL_CLOUD, UPDATE_INTERVAL_LOCAL, - AwairResult, ) PLATFORMS = [Platform.SENSOR] @@ -72,6 +77,14 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok +@dataclass +class AwairResult: + """Wrapper class to hold an awair device and set of air data.""" + + device: AwairBaseDevice + air_data: AirData + + class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): """Define a wrapper class to update Awair data.""" diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index d483df64298..19341ab6050 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -1,28 +1,9 @@ """Constants for the Awair component.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from python_awair.air_data import AirData -from python_awair.devices import AwairBaseDevice - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_BILLION, - CONCENTRATION_PARTS_PER_MILLION, - LIGHT_LUX, - PERCENTAGE, - UnitOfSoundPressure, - UnitOfTemperature, -) - API_CO2 = "carbon_dioxide" API_DUST = "dust" API_HUMID = "humidity" @@ -39,109 +20,7 @@ ATTRIBUTION = "Awair air quality sensor" DOMAIN = "awair" -DUST_ALIASES = [API_PM25, API_PM10] - LOGGER = logging.getLogger(__package__) UPDATE_INTERVAL_CLOUD = timedelta(minutes=5) UPDATE_INTERVAL_LOCAL = timedelta(seconds=30) - - -@dataclass -class AwairRequiredKeysMixin: - """Mixin for required keys.""" - - unique_id_tag: str - - -@dataclass -class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): - """Describes Awair sensor entity.""" - - -SENSOR_TYPE_SCORE = AwairSensorEntityDescription( - key=API_SCORE, - icon="mdi:blur", - native_unit_of_measurement=PERCENTAGE, - name="Score", - unique_id_tag="score", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, -) - -SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( - AwairSensorEntityDescription( - key=API_HUMID, - device_class=SensorDeviceClass.HUMIDITY, - native_unit_of_measurement=PERCENTAGE, - name="Humidity", - unique_id_tag="HUMID", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_LUX, - device_class=SensorDeviceClass.ILLUMINANCE, - native_unit_of_measurement=LIGHT_LUX, - name="Illuminance", - unique_id_tag="illuminance", - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_SPL_A, - device_class=SensorDeviceClass.SOUND_PRESSURE, - native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, - name="Sound level", - unique_id_tag="sound_level", - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_VOC, - icon="mdi:molecule", - native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, - name="Volatile organic compounds", - unique_id_tag="VOC", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_TEMP, - device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Temperature", - unique_id_tag="TEMP", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_CO2, - device_class=SensorDeviceClass.CO2, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - name="Carbon dioxide", - unique_id_tag="CO2", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), -) - -SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( - AwairSensorEntityDescription( - key=API_PM25, - device_class=SensorDeviceClass.PM25, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - name="PM2.5", - unique_id_tag="PM25", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), - AwairSensorEntityDescription( - key=API_PM10, - device_class=SensorDeviceClass.PM10, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - name="PM10", - unique_id_tag="PM10", # matches legacy format - state_class=SensorStateClass.MEASUREMENT, - ), -) - - -@dataclass -class AwairResult: - """Wrapper class to hold an awair device and set of air data.""" - - device: AwairBaseDevice - air_data: AirData diff --git a/homeassistant/components/awair/manifest.json b/homeassistant/components/awair/manifest.json index 19e3339cef6..25257bc3e1c 100644 --- a/homeassistant/components/awair/manifest.json +++ b/homeassistant/components/awair/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/awair", "iot_class": "local_polling", "loggers": ["python_awair"], - "requirements": ["python_awair==0.2.4"], + "requirements": ["python-awair==0.2.4"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index f42a46999fb..ee0febf1455 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -1,14 +1,30 @@ """Support for Awair sensors.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any, cast from python_awair.air_data import AirData from python_awair.devices import AwairBaseDevice, AwairLocalDevice -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CONNECTIONS, ATTR_SW_VERSION +from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_SW_VERSION, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + UnitOfSoundPressure, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo @@ -17,18 +33,106 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AwairDataUpdateCoordinator, AwairResult from .const import ( + API_CO2, API_DUST, + API_HUMID, + API_LUX, + API_PM10, API_PM25, API_SCORE, + API_SPL_A, API_TEMP, API_VOC, ATTRIBUTION, DOMAIN, - DUST_ALIASES, - SENSOR_TYPE_SCORE, - SENSOR_TYPES, - SENSOR_TYPES_DUST, - AwairSensorEntityDescription, +) + +DUST_ALIASES = [API_PM25, API_PM10] + + +@dataclass +class AwairRequiredKeysMixin: + """Mixin for required keys.""" + + unique_id_tag: str + + +@dataclass +class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): + """Describes Awair sensor entity.""" + + +SENSOR_TYPE_SCORE = AwairSensorEntityDescription( + key=API_SCORE, + icon="mdi:blur", + native_unit_of_measurement=PERCENTAGE, + translation_key="score", + unique_id_tag="score", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, +) + +SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_HUMID, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + unique_id_tag="HUMID", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + unique_id_tag="illuminance", + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_SPL_A, + device_class=SensorDeviceClass.SOUND_PRESSURE, + native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + translation_key="sound_level", + unique_id_tag="sound_level", + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_VOC, + icon="mdi:molecule", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + unique_id_tag="VOC", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_TEMP, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + unique_id_tag="TEMP", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_CO2, + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + unique_id_tag="CO2", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), +) + +SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_PM25, + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + unique_id_tag="PM25", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), + AwairSensorEntityDescription( + key=API_PM10, + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + unique_id_tag="PM10", # matches legacy format + state_class=SensorStateClass.MEASUREMENT, + ), ) diff --git a/homeassistant/components/awair/strings.json b/homeassistant/components/awair/strings.json index 6040bc1d7b0..731cd5db8dd 100644 --- a/homeassistant/components/awair/strings.json +++ b/homeassistant/components/awair/strings.json @@ -48,5 +48,15 @@ "unreachable": "[%key:common::config_flow::error::cannot_connect%]" }, "flow_title": "{model} ({device_id})" + }, + "entity": { + "sensor": { + "score": { + "name": "Score" + }, + "sound_level": { + "name": "Sound level" + } + } } } diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py index fcfd0f3241d..a68e80c3ac2 100644 --- a/homeassistant/components/baf/binary_sensor.py +++ b/homeassistant/components/baf/binary_sensor.py @@ -39,7 +39,6 @@ class BAFBinarySensorDescription( OCCUPANCY_SENSORS = ( BAFBinarySensorDescription( key="occupancy", - name="Occupancy", device_class=BinarySensorDeviceClass.OCCUPANCY, value_fn=lambda device: cast(bool | None, device.fan_occupancy_detected), ), @@ -70,7 +69,7 @@ class BAFBinarySensor(BAFEntity, BinarySensorEntity): def __init__(self, device: Device, description: BAFBinarySensorDescription) -> None: """Initialize the entity.""" self.entity_description = description - super().__init__(device, f"{device.name} {description.name}") + super().__init__(device) self._attr_unique_id = f"{self._device.mac_address}-{description.key}" @callback diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py index 6798639e7a8..531659e901f 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -27,9 +27,7 @@ async def async_setup_entry( """Set up BAF fan auto comfort.""" data: BAFData = hass.data[DOMAIN][entry.entry_id] if data.device.has_fan and data.device.has_auto_comfort: - async_add_entities( - [BAFAutoComfort(data.device, f"{data.device.name} Auto Comfort")] - ) + async_add_entities([BAFAutoComfort(data.device)]) class BAFAutoComfort(BAFEntity, ClimateEntity): @@ -38,6 +36,7 @@ class BAFAutoComfort(BAFEntity, ClimateEntity): _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] + _attr_translation_key = "auto_comfort" @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/baf/entity.py b/homeassistant/components/baf/entity.py index 22054d0b16d..4aeb287b861 100644 --- a/homeassistant/components/baf/entity.py +++ b/homeassistant/components/baf/entity.py @@ -13,12 +13,12 @@ class BAFEntity(Entity): """Base class for baf entities.""" _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, device: Device, name: str) -> None: + def __init__(self, device: Device) -> None: """Initialize the entity.""" self._device = device self._attr_unique_id = format_mac(self._device.mac_address) - self._attr_name = name self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac_address)}, name=self._device.name, diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index a166c346f12..059603fc589 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -33,7 +33,7 @@ async def async_setup_entry( """Set up SenseME fans.""" data: BAFData = hass.data[DOMAIN][entry.entry_id] if data.device.has_fan: - async_add_entities([BAFFan(data.device, data.device.name)]) + async_add_entities([BAFFan(data.device)]) class BAFFan(BAFEntity, FanEntity): @@ -46,6 +46,7 @@ class BAFFan(BAFEntity, FanEntity): ) _attr_preset_modes = [PRESET_MODE_AUTO] _attr_speed_count = SPEED_COUNT + _attr_name = None @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py index b177d383cd5..9557005e5eb 100644 --- a/homeassistant/components/baf/light.py +++ b/homeassistant/components/baf/light.py @@ -63,9 +63,11 @@ class BAFLight(BAFEntity, LightEntity): class BAFFanLight(BAFLight): """Representation of a Big Ass Fans light on a fan.""" + _attr_name = None + def __init__(self, device: Device) -> None: """Init a fan light.""" - super().__init__(device, device.name) + super().__init__(device) self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_color_mode = ColorMode.BRIGHTNESS @@ -75,7 +77,7 @@ class BAFStandaloneLight(BAFLight): def __init__(self, device: Device) -> None: """Init a standalone light.""" - super().__init__(device, f"{device.name} Light") + super().__init__(device) self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_min_mireds = color_temperature_kelvin_to_mired( diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 020f34fefaf..7fd1c9ed290 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -37,7 +37,7 @@ class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_min_speed", - name="Auto Comfort Minimum Speed", + translation_key="comfort_min_speed", native_step=1, native_min_value=0, native_max_value=SPEED_RANGE[1] - 1, @@ -47,7 +47,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( ), BAFNumberDescription( key="comfort_max_speed", - name="Auto Comfort Maximum Speed", + translation_key="comfort_max_speed", native_step=1, native_min_value=1, native_max_value=SPEED_RANGE[1], @@ -57,7 +57,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( ), BAFNumberDescription( key="comfort_heat_assist_speed", - name="Auto Comfort Heat Assist Speed", + translation_key="comfort_heat_assist_speed", native_step=1, native_min_value=SPEED_RANGE[0], native_max_value=SPEED_RANGE[1], @@ -70,7 +70,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="return_to_auto_timeout", - name="Return to Auto Timeout", + translation_key="return_to_auto_timeout", native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, @@ -81,7 +81,7 @@ FAN_NUMBER_DESCRIPTIONS = ( ), BAFNumberDescription( key="motion_sense_timeout", - name="Motion Sense Timeout", + translation_key="motion_sense_timeout", native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, @@ -95,7 +95,7 @@ FAN_NUMBER_DESCRIPTIONS = ( LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_return_to_auto_timeout", - name="Light Return to Auto Timeout", + translation_key="light_return_to_auto_timeout", native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, @@ -106,7 +106,7 @@ LIGHT_NUMBER_DESCRIPTIONS = ( ), BAFNumberDescription( key="light_auto_motion_timeout", - name="Light Motion Sense Timeout", + translation_key="light_auto_motion_timeout", native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, @@ -144,7 +144,7 @@ class BAFNumber(BAFEntity, NumberEntity): def __init__(self, device: Device, description: BAFNumberDescription) -> None: """Initialize the entity.""" self.entity_description = description - super().__init__(device, f"{device.name} {description.name}") + super().__init__(device) self._attr_unique_id = f"{self._device.mac_address}-{description.key}" @callback diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index d8700886e0a..d8111804142 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -46,7 +46,6 @@ class BAFSensorDescription( AUTO_COMFORT_SENSORS = ( BAFSensorDescription( key="temperature", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -57,7 +56,6 @@ AUTO_COMFORT_SENSORS = ( DEFINED_ONLY_SENSORS = ( BAFSensorDescription( key="humidity", - name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -68,7 +66,7 @@ DEFINED_ONLY_SENSORS = ( FAN_SENSORS = ( BAFSensorDescription( key="current_rpm", - name="Current RPM", + translation_key="current_rpm", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -76,7 +74,7 @@ FAN_SENSORS = ( ), BAFSensorDescription( key="target_rpm", - name="Target RPM", + translation_key="target_rpm", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -84,14 +82,14 @@ FAN_SENSORS = ( ), BAFSensorDescription( key="wifi_ssid", - name="WiFi SSID", + translation_key="wifi_ssid", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: cast(int | None, device.wifi_ssid), ), BAFSensorDescription( key="ip_address", - name="IP Address", + translation_key="ip_address", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: cast(str | None, device.ip_address), @@ -128,7 +126,7 @@ class BAFSensor(BAFEntity, SensorEntity): def __init__(self, device: Device, description: BAFSensorDescription) -> None: """Initialize the entity.""" self.entity_description = description - super().__init__(device, f"{device.name} {description.name}") + super().__init__(device) self._attr_unique_id = f"{self._device.mac_address}-{description.key}" @callback diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json index 59a20ea400c..cb322320675 100644 --- a/homeassistant/components/baf/strings.json +++ b/homeassistant/components/baf/strings.json @@ -19,5 +19,81 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "climate": { + "auto_comfort": { + "name": "Auto comfort" + } + }, + "number": { + "comfort_min_speed": { + "name": "Auto Comfort Minimum Speed" + }, + "comfort_max_speed": { + "name": "Auto Comfort Maximum Speed" + }, + "comfort_heat_assist_speed": { + "name": "Auto Comfort Heat Assist Speed" + }, + "return_to_auto_timeout": { + "name": "Return to Auto Timeout" + }, + "motion_sense_timeout": { + "name": "Motion Sense Timeout" + }, + "light_return_to_auto_timeout": { + "name": "Light Return to Auto Timeout" + }, + "light_auto_motion_timeout": { + "name": "Light Motion Sense Timeout" + } + }, + "sensor": { + "current_rpm": { + "name": "Current RPM" + }, + "target_rpm": { + "name": "Target RPM" + }, + "wifi_ssid": { + "name": "Wi-Fi SSID" + }, + "ip_address": { + "name": "IP Address" + } + }, + "switch": { + "legacy_ir_remote_enable": { + "name": "Legacy IR Remote" + }, + "led_indicators_enable": { + "name": "Led Indicators" + }, + "comfort_heat_assist_enable": { + "name": "Auto Comfort Heat Assist" + }, + "fan_beep_enable": { + "name": "Beep" + }, + "eco_enable": { + "name": "Eco Mode" + }, + "motion_sense_enable": { + "name": "Motion Sense" + }, + "return_to_auto_enable": { + "name": "Return to Auto" + }, + "whoosh_enable": { + "name": "Whoosh" + }, + "light_dim_to_warm_enable": { + "name": "Dim to Warm" + }, + "light_return_to_auto_enable": { + "name": "Light Return to Auto" + } + } } } diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py index d5236f9b861..ed4e635ece3 100644 --- a/homeassistant/components/baf/switch.py +++ b/homeassistant/components/baf/switch.py @@ -36,13 +36,13 @@ class BAFSwitchDescription( BASE_SWITCHES = [ BAFSwitchDescription( key="legacy_ir_remote_enable", - name="Legacy IR Remote", + translation_key="legacy_ir_remote_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.legacy_ir_remote_enable), ), BAFSwitchDescription( key="led_indicators_enable", - name="Led Indicators", + translation_key="led_indicators_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.led_indicators_enable), ), @@ -51,7 +51,7 @@ BASE_SWITCHES = [ AUTO_COMFORT_SWITCHES = [ BAFSwitchDescription( key="comfort_heat_assist_enable", - name="Auto Comfort Heat Assist", + translation_key="comfort_heat_assist_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.comfort_heat_assist_enable), ), @@ -60,31 +60,31 @@ AUTO_COMFORT_SWITCHES = [ FAN_SWITCHES = [ BAFSwitchDescription( key="fan_beep_enable", - name="Beep", + translation_key="fan_beep_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.fan_beep_enable), ), BAFSwitchDescription( key="eco_enable", - name="Eco Mode", + translation_key="eco_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.eco_enable), ), BAFSwitchDescription( key="motion_sense_enable", - name="Motion Sense", + translation_key="motion_sense_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.motion_sense_enable), ), BAFSwitchDescription( key="return_to_auto_enable", - name="Return to Auto", + translation_key="return_to_auto_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.return_to_auto_enable), ), BAFSwitchDescription( key="whoosh_enable", - name="Whoosh", + translation_key="whoosh_enable", # Not a configuration switch value_fn=lambda device: cast(bool | None, device.whoosh_enable), ), @@ -94,13 +94,13 @@ FAN_SWITCHES = [ LIGHT_SWITCHES = [ BAFSwitchDescription( key="light_dim_to_warm_enable", - name="Dim to Warm", + translation_key="light_dim_to_warm_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.light_dim_to_warm_enable), ), BAFSwitchDescription( key="light_return_to_auto_enable", - name="Light Return to Auto", + translation_key="light_return_to_auto_enable", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(bool | None, device.light_return_to_auto_enable), ), @@ -134,7 +134,7 @@ class BAFSwitch(BAFEntity, SwitchEntity): def __init__(self, device: Device, description: BAFSwitchDescription) -> None: """Initialize the entity.""" self.entity_description = description - super().__init__(device, f"{device.name} {description.name}") + super().__init__(device) self._attr_unique_id = f"{self._device.mac_address}-{description.key}" @callback diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 11a0cae0a01..9f363746a8f 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -47,6 +47,10 @@ class BalboaBinarySensorEntityDescription( ): """A class that describes Balboa binary sensor entities.""" + # BalboaBinarySensorEntity does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + FILTER_CYCLE_ICONS = ("mdi:sync", "mdi:sync-off") BINARY_SENSOR_DESCRIPTIONS = ( diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index 06e8d265502..0d0fa9bd179 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -78,7 +78,7 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity): return HEAT_HVAC_MODE_MAP.get(self._client.heat_mode.state) @property - def hvac_action(self) -> str: + def hvac_action(self) -> HVACAction: """Return the current operation mode.""" return HEAT_STATE_HVAC_ACTION_MAP[self._client.heat_state] diff --git a/homeassistant/components/beewi_smartclim/manifest.json b/homeassistant/components/beewi_smartclim/manifest.json index f238c76d366..3555f9181bb 100644 --- a/homeassistant/components/beewi_smartclim/manifest.json +++ b/homeassistant/components/beewi_smartclim/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/beewi_smartclim", "iot_class": "local_polling", "loggers": ["beewi_smartclim"], - "requirements": ["beewi_smartclim==0.0.10"] + "requirements": ["beewi-smartclim==0.0.10"] } diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index d99f569ed59..1c2d6d779fb 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -190,6 +190,13 @@ class BinarySensorEntity(Entity): _attr_is_on: bool | None = None _attr_state: None = None + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For binary sensors this is True if the entity has a device class. + """ + return self.device_class is not None + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of this entity.""" diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index 4da9bd45670..81d2ebf26a2 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -256,7 +256,7 @@ ENTITY_CONDITIONS = { CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(IS_OFF + IS_ON), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -287,7 +287,7 @@ async def async_get_conditions( **template, "condition": "device", "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": DOMAIN, } for template in templates diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index b2fd371a260..de6dbdbe075 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -112,8 +112,8 @@ ENTITY_TRIGGERS = { {CONF_TYPE: CONF_NO_LIGHT}, ], BinarySensorDeviceClass.LOCK: [ - {CONF_TYPE: CONF_LOCKED}, {CONF_TYPE: CONF_NOT_LOCKED}, + {CONF_TYPE: CONF_LOCKED}, ], BinarySensorDeviceClass.MOISTURE: [ {CONF_TYPE: CONF_MOIST}, @@ -195,7 +195,7 @@ TURNED_OFF = [trigger[1][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()] TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TURNED_OFF + TURNED_ON), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -254,7 +254,7 @@ async def async_get_triggers( **automation, "platform": "device", "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": DOMAIN, } for automation in templates diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index ca349e19328..b9c9b19a93c 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -302,7 +302,7 @@ } }, "device_class": { - "co": "carbon_monoxide", + "co": "carbon monoxide", "cold": "cold", "gas": "gas", "heat": "heat", diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index 8cb7ddb5c1e..b639e28d698 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.1.4"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 668a7f99c02..b94a77fbf18 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -8,7 +8,13 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_FILENAME, CONF_NAME, CONF_PIN, CONF_SCAN_INTERVAL +from homeassistant.const import ( + CONF_FILE_PATH, + CONF_FILENAME, + CONF_NAME, + CONF_PIN, + CONF_SCAN_INTERVAL, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -18,6 +24,7 @@ from .const import ( DOMAIN, PLATFORMS, SERVICE_REFRESH, + SERVICE_SAVE_RECENT_CLIPS, SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) @@ -28,6 +35,9 @@ SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string} ) SERVICE_SEND_PIN_SCHEMA = vol.Schema({vol.Optional(CONF_PIN): cv.string}) +SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( + {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string} +) def _blink_startup_wrapper(hass: HomeAssistant, entry: ConfigEntry) -> Blink: @@ -100,6 +110,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Call save video service handler.""" await async_handle_save_video_service(hass, entry, call) + async def async_save_recent_clips(call): + """Call save recent clips service handler.""" + await async_handle_save_recent_clips_service(hass, entry, call) + def send_pin(call): """Call blink to send new pin.""" pin = call.data[CONF_PIN] @@ -112,6 +126,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.services.async_register( DOMAIN, SERVICE_SAVE_VIDEO, async_save_video, schema=SERVICE_SAVE_VIDEO_SCHEMA ) + hass.services.async_register( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + async_save_recent_clips, + schema=SERVICE_SAVE_RECENT_CLIPS_SCHEMA, + ) hass.services.async_register( DOMAIN, SERVICE_SEND_PIN, send_pin, schema=SERVICE_SEND_PIN_SCHEMA ) @@ -164,13 +184,33 @@ async def async_handle_save_video_service(hass, entry, call): _LOGGER.error("Can't write %s, no access to path!", video_path) return - def _write_video(camera_name, video_path): + def _write_video(name, file_path): """Call video write.""" all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if camera_name in all_cameras: - all_cameras[camera_name].video_to_file(video_path) + 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) + + +async def async_handle_save_recent_clips_service(hass, entry, call): + """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) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 22a142ff44c..5d0ea67f31d 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -41,6 +41,7 @@ class BlinkSyncModule(AlarmControlPanelEntity): _attr_icon = ICON _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_name = None def __init__(self, data, name, sync): """Initialize the alarm control panel.""" @@ -48,15 +49,23 @@ class BlinkSyncModule(AlarmControlPanelEntity): self.sync = sync self._name = name self._attr_unique_id = sync.serial - self._attr_name = f"{DOMAIN} {name}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, sync.serial)}, name=name, manufacturer=DEFAULT_BRAND + identifiers={(DOMAIN, sync.serial)}, + name=f"{DOMAIN} {name}", + manufacturer=DEFAULT_BRAND, ) def update(self) -> None: """Update the state of the device.""" - _LOGGER.debug("Updating Blink Alarm Control Panel %s", self._name) - self.data.refresh() + 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) + self._attr_state = ( STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED ) diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 9454daa85ec..c7daf0ec1e1 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -1,6 +1,8 @@ """Support for Blink system camera control.""" from __future__ import annotations +import logging + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -20,20 +22,20 @@ from .const import ( TYPE_MOTION_DETECTED, ) +_LOGGER = logging.getLogger(__name__) + BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key=TYPE_BATTERY, - name="Battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensorEntityDescription( key=TYPE_CAMERA_ARMED, - name="Camera Armed", + translation_key="camera_armed", ), BinarySensorEntityDescription( key=TYPE_MOTION_DETECTED, - name="Motion Detected", device_class=BinarySensorDeviceClass.MOTION, ), ) @@ -74,8 +76,13 @@ class BlinkBinarySensor(BinarySensorEntity): def update(self) -> None: """Update sensor state.""" - self.data.refresh() state = self._camera.attributes[self.entity_description.key] + _LOGGER.debug( + "'%s' %s = %s", + self._camera.attributes["name"], + self.entity_description.key, + state, + ) if self.entity_description.key == TYPE_BATTERY: state = state != "ok" self._attr_is_on = state diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index e500eb79e42..e74555f8db9 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -38,11 +38,12 @@ async def async_setup_entry( class BlinkCamera(Camera): """An implementation of a Blink Camera.""" + _attr_name = None + def __init__(self, data, name, camera): """Initialize a camera.""" super().__init__() self.data = data - self._attr_name = f"{DOMAIN} {name}" self._camera = camera self._attr_unique_id = f"{camera.serial}-camera" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 8986782031f..d58920562f4 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -23,6 +23,7 @@ TYPE_WIFI_STRENGTH = "wifi_strength" SERVICE_REFRESH = "blink_update" SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" +SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" PLATFORMS = [ diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index c051fef98f4..c996a90e54d 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -25,14 +25,13 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=TYPE_TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key=TYPE_WIFI_STRENGTH, - name="Wifi Signal", + translation_key="wifi_rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, @@ -57,10 +56,11 @@ async def async_setup_entry( class BlinkSensor(SensorEntity): """A Blink camera sensor.""" + _attr_has_entity_name = True + def __init__(self, data, camera, description: SensorEntityDescription) -> None: """Initialize sensors from Blink camera.""" self.entity_description = description - self._attr_name = f"{DOMAIN} {camera} {description.name}" self.data = data self._camera = data.cameras[camera] self._attr_unique_id = f"{self._camera.serial}-{description.key}" @@ -71,16 +71,21 @@ class BlinkSensor(SensorEntity): ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._camera.serial)}, - name=camera, + name=f"{DOMAIN} {camera}", manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, ) def update(self) -> None: """Retrieve sensor data from the camera.""" - self.data.refresh() try: self._attr_native_value = self._camera.attributes[self._sensor_key] + _LOGGER.debug( + "'%s' %s = %s", + self._camera.attributes["name"], + self._sensor_key, + self._attr_native_value, + ) except KeyError: self._attr_native_value = None _LOGGER.error( diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 89af4799c85..3d51ba2f7bb 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -25,12 +25,31 @@ save_video: text: filename: name: File name - description: Filename to writable path (directory may need to be included in whitelist_dirs in config) + description: Filename to writable path (directory may need to be included in allowlist_external_dirs in config) required: true example: "/tmp/video.mp4" selector: text: +save_recent_clips: + name: Save recent clips + description: 'Save all recent video clips to local directory with file pattern "%Y%m%d_%H%M%S_{name}.mp4"' + fields: + name: + name: Name + description: Name of camera to grab recent clips from. + required: true + example: "Living Room" + selector: + text: + file_path: + name: Output directory + description: Directory name of writable path (directory may need to be included in allowlist_external_dirs in config) + required: true + example: "/tmp" + selector: + text: + send_pin: name: Send pin description: Send a new PIN to blink for 2FA. diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index ae04f37714b..61c9a21af37 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -34,5 +34,17 @@ "description": "Configure Blink integration" } } + }, + "entity": { + "sensor": { + "wifi_rssi": { + "name": "Wi-Fi RSSI" + } + }, + "binary_sensor": { + "camera_armed": { + "name": "Camera armed" + } + } } } diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json index 70e5c2a4672..e3a6638f2a9 100644 --- a/homeassistant/components/blinksticklight/manifest.json +++ b/homeassistant/components/blinksticklight/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/blinksticklight", "iot_class": "local_polling", "loggers": ["blinkstick"], - "requirements": ["blinkstick==1.2.0"] + "requirements": ["BlinkStick==1.2.0"] } diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index d857992a13c..4517d134e69 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -1,6 +1,8 @@ """Import logic for blueprint.""" + from __future__ import annotations +from contextlib import suppress from dataclasses import dataclass import html import re @@ -28,6 +30,10 @@ GITHUB_FILE_PATTERN = re.compile( r"^https://github.com/(?P.+)/blob/(?P.+)$" ) +WEBSITE_PATTERN = re.compile( + r"^https://(?P[a-z0-9-]+)\.home-assistant\.io/(?P.+).yaml$" +) + COMMUNITY_TOPIC_SCHEMA = vol.Schema( { "slug": str, @@ -219,18 +225,37 @@ async def fetch_blueprint_from_github_gist_url( ) +async def fetch_blueprint_from_website_url( + hass: HomeAssistant, url: str +) -> ImportedBlueprint: + """Get a blueprint from our website.""" + if (WEBSITE_PATTERN.match(url)) is None: + raise UnsupportedUrl("Not a Home Assistant website URL") + + session = aiohttp_client.async_get_clientsession(hass) + + resp = await session.get(url, raise_for_status=True) + raw_yaml = await resp.text() + data = yaml.parse_yaml(raw_yaml) + assert isinstance(data, dict) + blueprint = Blueprint(data) + + parsed_import_url = yarl.URL(url) + suggested_filename = f"homeassistant/{parsed_import_url.parts[-1][:-5]}" + return ImportedBlueprint(suggested_filename, raw_yaml, blueprint) + + async def fetch_blueprint_from_url(hass: HomeAssistant, url: str) -> ImportedBlueprint: """Get a blueprint from a url.""" for func in ( fetch_blueprint_from_community_post, fetch_blueprint_from_github_url, fetch_blueprint_from_github_gist_url, + fetch_blueprint_from_website_url, ): - try: + with suppress(UnsupportedUrl): imported_bp = await func(hass, url) imported_bp.blueprint.update_metadata(source_url=url) return imported_bp - except UnsupportedUrl: - pass - raise HomeAssistantError("Unsupported url") + raise HomeAssistantError("Unsupported URL") diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 8d3bc0ae5e2..bf4dbf81f01 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -76,7 +76,7 @@ from .models import ( BluetoothScanningMode, HaBluetoothConnector, ) -from .scanner import HaScanner, ScannerStartError +from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError from .storage import BluetoothStorage if TYPE_CHECKING: @@ -108,6 +108,7 @@ __all__ = [ "HaBluetoothConnector", "SOURCE_LOCAL", "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", + "MONOTONIC_TIME", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 8f7750fe322..e8de285138e 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -299,10 +299,10 @@ class BaseHaRemoteScanner(BaseHaScanner): manufacturer_data: dict[int, bytes], tx_power: int | None, details: dict[Any, Any], + advertisement_monotonic_time: float, ) -> None: """Call the registered callback.""" - now = MONOTONIC_TIME() - self._last_detection = now + self._last_detection = advertisement_monotonic_time if prev_discovery := self._discovered_device_advertisement_datas.get(address): # Merge the new data with the old data # to function the same as BlueZ which @@ -365,7 +365,7 @@ class BaseHaRemoteScanner(BaseHaScanner): device, advertisement_data, ) - self._discovered_device_timestamps[address] = now + self._discovered_device_timestamps[address] = advertisement_monotonic_time self._new_info_callback( BluetoothServiceInfoBleak( name=local_name or address, @@ -378,7 +378,7 @@ class BaseHaRemoteScanner(BaseHaScanner): device=device, advertisement=advertisement_data, connectable=self.connectable, - time=now, + time=advertisement_monotonic_time, ) ) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 3210822e795..f1221290c74 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -246,9 +246,12 @@ class BluetoothManager: self, address: str, connectable: bool ) -> list[BluetoothScannerDevice]: """Get BluetoothScannerDevice by address.""" - scanners = self._get_scanners_by_type(True) if not connectable: - scanners.extend(self._get_scanners_by_type(False)) + scanners: Iterable[BaseHaScanner] = itertools.chain( + self._connectable_scanners, self._non_connectable_scanners + ) + else: + scanners = self._connectable_scanners return [ BluetoothScannerDevice(scanner, *device_adv) for scanner in scanners @@ -267,21 +270,19 @@ class BluetoothManager: """ yield from itertools.chain.from_iterable( scanner.discovered_devices_and_advertisement_data - for scanner in self._get_scanners_by_type(True) + for scanner in self._connectable_scanners ) if not connectable: yield from itertools.chain.from_iterable( scanner.discovered_devices_and_advertisement_data - for scanner in self._get_scanners_by_type(False) + for scanner in self._non_connectable_scanners ) @hass_callback def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: """Return all of combined best path to discovered from all the scanners.""" - return [ - history.device - for history in self._get_history_by_type(connectable).values() - ] + histories = self._connectable_history if connectable else self._all_history + return [history.device for history in histories.values()] @hass_callback def async_setup_unavailable_tracking(self) -> None: @@ -303,7 +304,10 @@ class BluetoothManager: intervals = tracker.intervals for connectable in (True, False): - unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable) + if connectable: + unavailable_callbacks = self._connectable_unavailable_callbacks + else: + unavailable_callbacks = self._unavailable_callbacks history = connectable_history if connectable else all_history disappeared = set(history).difference( self._async_all_discovered_addresses(connectable) @@ -583,7 +587,10 @@ class BluetoothManager: connectable: bool, ) -> Callable[[], None]: """Register a callback.""" - unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable) + if connectable: + unavailable_callbacks = self._connectable_unavailable_callbacks + else: + unavailable_callbacks = self._unavailable_callbacks unavailable_callbacks.setdefault(address, []).append(callback) @hass_callback @@ -620,13 +627,13 @@ class BluetoothManager: # If we have history for the subscriber, we can trigger the callback # immediately with the last packet so the subscriber can see the # device. - all_history = self._get_history_by_type(connectable) + history = self._connectable_history if connectable else self._all_history service_infos: Iterable[BluetoothServiceInfoBleak] = [] if address := callback_matcher.get(ADDRESS): - if service_info := all_history.get(address): + if service_info := history.get(address): service_infos = [service_info] else: - service_infos = all_history.values() + service_infos = history.values() for service_info in service_infos: if ble_device_matches(callback_matcher, service_info): @@ -642,29 +649,32 @@ class BluetoothManager: self, address: str, connectable: bool ) -> BLEDevice | None: """Return the BLEDevice if present.""" - all_history = self._get_history_by_type(connectable) - if history := all_history.get(address): + histories = self._connectable_history if connectable else self._all_history + if history := histories.get(address): return history.device return None @hass_callback def async_address_present(self, address: str, connectable: bool) -> bool: """Return if the address is present.""" - return address in self._get_history_by_type(connectable) + histories = self._connectable_history if connectable else self._all_history + return address in histories @hass_callback def async_discovered_service_info( self, connectable: bool ) -> Iterable[BluetoothServiceInfoBleak]: """Return all the discovered services info.""" - return self._get_history_by_type(connectable).values() + histories = self._connectable_history if connectable else self._all_history + return histories.values() @hass_callback def async_last_service_info( self, address: str, connectable: bool ) -> BluetoothServiceInfoBleak | None: """Return the last service info for an address.""" - return self._get_history_by_type(connectable).get(address) + histories = self._connectable_history if connectable else self._all_history + return histories.get(address) def _async_trigger_matching_discovery( self, service_info: BluetoothServiceInfoBleak @@ -688,26 +698,6 @@ class BluetoothManager: if service_info := self._all_history.get(address): self._async_trigger_matching_discovery(service_info) - def _get_scanners_by_type(self, connectable: bool) -> list[BaseHaScanner]: - """Return the scanners by type.""" - if connectable: - return self._connectable_scanners - return self._non_connectable_scanners - - def _get_unavailable_callbacks_by_type( - self, connectable: bool - ) -> dict[str, list[Callable[[BluetoothServiceInfoBleak], None]]]: - """Return the unavailable callbacks by type.""" - if connectable: - return self._connectable_unavailable_callbacks - return self._unavailable_callbacks - - def _get_history_by_type( - self, connectable: bool - ) -> dict[str, BluetoothServiceInfoBleak]: - """Return the history by type.""" - return self._connectable_history if connectable else self._all_history - def async_register_scanner( self, scanner: BaseHaScanner, @@ -716,7 +706,10 @@ class BluetoothManager: ) -> CALLBACK_TYPE: """Register a new scanner.""" _LOGGER.debug("Registering scanner %s", scanner.name) - scanners = self._get_scanners_by_type(connectable) + if connectable: + scanners = self._connectable_scanners + else: + scanners = self._non_connectable_scanners def _unregister_scanner() -> None: _LOGGER.debug("Unregistering scanner %s", scanner.name) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 8d936b7286f..dbe8ac3f1ab 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.2.0", - "bluetooth-data-tools==0.4.0", + "bluetooth-data-tools==1.3.0", "dbus-fast==1.86.0" ] } diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json index b1411a41f87..0a0356e6669 100644 --- a/homeassistant/components/bluetooth_tracker/manifest.json +++ b/homeassistant/components/bluetooth_tracker/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker", "iot_class": "local_polling", "loggers": ["bluetooth", "bt_proximity"], - "requirements": ["bt_proximity==0.2.1", "pybluez==0.22"] + "requirements": ["bt-proximity==0.2.1", "PyBluez==0.22"] } diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 640f4e3653b..c3be7ae189b 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -37,6 +37,7 @@ ALLOWED_CONDITION_BASED_SERVICE_KEYS = { "TIRE_WEAR_REAR", "VEHICLE_CHECK", "VEHICLE_TUV", + "WASHING_FLUID", } LOGGED_CONDITION_BASED_SERVICE_WARNINGS: set[str] = set() @@ -122,7 +123,7 @@ class BMWBinarySensorEntityDescription( SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( BMWBinarySensorEntityDescription( key="lids", - name="Lids", + translation_key="lids", device_class=BinarySensorDeviceClass.OPENING, icon="mdi:car-door-lock", # device class opening: On means open, Off means closed @@ -133,7 +134,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="windows", - name="Windows", + translation_key="windows", device_class=BinarySensorDeviceClass.OPENING, icon="mdi:car-door", # device class opening: On means open, Off means closed @@ -144,7 +145,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="door_lock_state", - name="Door lock state", + translation_key="door_lock_state", device_class=BinarySensorDeviceClass.LOCK, icon="mdi:car-key", # device class lock: On means unlocked, Off means locked @@ -157,7 +158,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="condition_based_services", - name="Condition based services", + translation_key="condition_based_services", device_class=BinarySensorDeviceClass.PROBLEM, icon="mdi:wrench", # device class problem: On means problem detected, Off means no problem @@ -166,7 +167,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="check_control_messages", - name="Check control messages", + translation_key="check_control_messages", device_class=BinarySensorDeviceClass.PROBLEM, icon="mdi:car-tire-alert", # device class problem: On means problem detected, Off means no problem @@ -176,7 +177,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( # electric BMWBinarySensorEntityDescription( key="charging_status", - name="Charging status", + translation_key="charging_status", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, icon="mdi:ev-station", # device class power: On means power detected, Off means no power @@ -184,14 +185,14 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( ), BMWBinarySensorEntityDescription( key="connection_status", - name="Connection status", + translation_key="connection_status", device_class=BinarySensorDeviceClass.PLUG, icon="mdi:car-electric", value_fn=lambda v: v.fuel_and_battery.is_charger_connected, ), BMWBinarySensorEntityDescription( key="is_pre_entry_climatization_enabled", - name="Pre entry climatization", + translation_key="is_pre_entry_climatization_enabled", icon="mdi:car-seat-heater", value_fn=lambda v: v.charging_profile.is_pre_entry_climatization_enabled if v.charging_profile diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 5285820b32d..6edb1a3f2ac 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -6,12 +6,14 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from bimmer_connected.vehicle.remote_services import RemoteServiceStatus from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BMWBaseEntity @@ -32,37 +34,45 @@ class BMWButtonEntityDescription(ButtonEntityDescription): [MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus] ] | None = None account_function: Callable[[BMWDataUpdateCoordinator], Coroutine] | None = None + is_available: Callable[[MyBMWVehicle], bool] = lambda _: True BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( BMWButtonEntityDescription( key="light_flash", + translation_key="light_flash", icon="mdi:car-light-alert", - name="Flash lights", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_light_flash(), ), BMWButtonEntityDescription( key="sound_horn", + translation_key="sound_horn", icon="mdi:bullhorn", - name="Sound horn", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(), ), BMWButtonEntityDescription( key="activate_air_conditioning", + translation_key="activate_air_conditioning", icon="mdi:hvac", - name="Activate air conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), ), + BMWButtonEntityDescription( + key="deactivate_air_conditioning", + icon="mdi:hvac-off", + name="Deactivate air conditioning", + remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), + is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled, + ), BMWButtonEntityDescription( key="find_vehicle", + translation_key="find_vehicle", icon="mdi:crosshairs-question", - name="Find vehicle", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_vehicle_finder(), ), BMWButtonEntityDescription( key="refresh", + translation_key="refresh", icon="mdi:refresh", - name="Refresh from cloud", account_function=lambda coordinator: coordinator.async_request_refresh(), enabled_when_read_only=True, ), @@ -84,7 +94,7 @@ async def async_setup_entry( [ BMWButton(coordinator, vehicle, description) for description in BUTTON_TYPES - if not coordinator.read_only + if (not coordinator.read_only and description.is_available(vehicle)) or (coordinator.read_only and description.enabled_when_read_only) ] ) @@ -111,7 +121,10 @@ class BMWButton(BMWBaseEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" if self.entity_description.remote_function: - await self.entity_description.remote_function(self.vehicle) + try: + await self.entity_description.remote_function(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex elif self.entity_description.account_function: _LOGGER.warning( "The 'Refresh from cloud' button is deprecated. Use the" @@ -120,6 +133,9 @@ class BMWButton(BMWBaseEntity, ButtonEntity): " https://www.home-assistant.io/integrations/bmw_connected_drive/#update-the-state--refresh-from-api" " for details" ) - await self.entity_description.account_function(self.coordinator) + try: + await self.entity_description.account_function(self.coordinator) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 37225fc052f..96ef152307d 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -21,3 +21,9 @@ UNIT_MAP = { "LITERS": UnitOfVolume.LITERS, "GALLONS": UnitOfVolume.GALLONS, } + +SCAN_INTERVALS = { + "china": 300, + "north_america": 600, + "rest_of_world": 300, +} diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index f6354422312..4a586aab373 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -15,10 +15,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN +from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS -DEFAULT_SCAN_INTERVAL_SECONDS = 300 -SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) _LOGGER = logging.getLogger(__name__) @@ -50,7 +48,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): hass, _LOGGER, name=f"{DOMAIN}-{entry.data['username']}", - update_interval=SCAN_INTERVAL, + update_interval=timedelta(seconds=SCAN_INTERVALS[entry.data[CONF_REGION]]), ) async def _async_update_data(self) -> None: diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index d20ccd1fbb4..6608206a0ee 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -4,12 +4,14 @@ from __future__ import annotations import logging from typing import Any +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from bimmer_connected.vehicle.doors_windows import LockState from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BMWBaseEntity @@ -44,7 +46,7 @@ async def async_setup_entry( class BMWLock(BMWBaseEntity, LockEntity): """Representation of a MyBMW vehicle lock.""" - _attr_name = "Lock" + _attr_translation_key = "lock" def __init__( self, @@ -66,7 +68,12 @@ class BMWLock(BMWBaseEntity, LockEntity): # update callback response self._attr_is_locked = True self.async_write_ha_state() - await self.vehicle.remote_services.trigger_remote_door_lock() + try: + await self.vehicle.remote_services.trigger_remote_door_lock() + except MyBMWAPIError as ex: + self._attr_is_locked = False + self.async_write_ha_state() + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() @@ -79,7 +86,12 @@ class BMWLock(BMWBaseEntity, LockEntity): # update callback response self._attr_is_locked = False self.async_write_ha_state() - await self.vehicle.remote_services.trigger_remote_door_unlock() + try: + await self.vehicle.remote_services.trigger_remote_door_unlock() + except MyBMWAPIError as ex: + self._attr_is_locked = True + self.async_write_ha_state() + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index d30198bdc12..82426fbce08 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.13.7"] + "requirements": ["bimmer-connected==0.13.8"] } diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py index 036d5147c4f..4a9f7679dc4 100644 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from typing import Any, cast +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from homeassistant.components.notify import ( @@ -19,6 +20,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -87,7 +89,11 @@ class BMWNotificationService(BaseNotificationService): if k in ATTR_LOCATION_ATTRIBUTES } ) - - await vehicle.remote_services.trigger_send_poi(location_dict) + try: + await vehicle.remote_services.trigger_send_poi(location_dict) + except TypeError as ex: + raise ValueError(str(ex)) from ex + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex else: raise ValueError(f"'data.{ATTR_LOCATION}' is required.") diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index c8f72b272c1..f37f7627140 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -45,7 +45,7 @@ class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): NUMBER_TYPES: list[BMWNumberEntityDescription] = [ BMWNumberEntityDescription( key="target_soc", - name="Target SoC", + translation_key="target_soc", device_class=NumberDeviceClass.BATTERY, is_available=lambda v: v.is_remote_set_target_soc_enabled, native_max_value=100.0, diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 0b20ed90873..3467322a4af 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -4,6 +4,7 @@ from dataclasses import dataclass import logging from typing import Any +from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle from bimmer_connected.vehicle.charging_profile import ChargingMode @@ -11,6 +12,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BMWBaseEntity @@ -39,7 +41,7 @@ class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { "ac_limit": BMWSelectEntityDescription( key="ac_limit", - name="AC Charging Limit", + translation_key="ac_limit", is_available=lambda v: v.is_remote_set_ac_limit_enabled, dynamic_options=lambda v: [ str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] @@ -53,7 +55,7 @@ SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { ), "charging_mode": BMWSelectEntityDescription( key="charging_mode", - name="Charging Mode", + translation_key="charging_mode", is_available=lambda v: v.is_charging_plan_supported, options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] @@ -123,6 +125,9 @@ class BMWSelect(BMWBaseEntity, SelectEntity): self.vehicle.vin, option, ) - await self.entity_description.remote_service(self.vehicle, option) + try: + await self.entity_description.remote_service(self.vehicle, option) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 314ff47c14c..8f5b4fb8608 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -55,7 +55,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { # --- Generic --- "ac_current_limit": BMWSensorEntityDescription( key="ac_current_limit", - name="AC current limit", + translation_key="ac_current_limit", key_class="charging_profile", unit_type=UnitOfElectricCurrent.AMPERE, icon="mdi:current-ac", @@ -63,34 +63,34 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "charging_start_time": BMWSensorEntityDescription( key="charging_start_time", - name="Charging start time", + translation_key="charging_start_time", key_class="fuel_and_battery", device_class=SensorDeviceClass.TIMESTAMP, entity_registry_enabled_default=False, ), "charging_end_time": BMWSensorEntityDescription( key="charging_end_time", - name="Charging end time", + translation_key="charging_end_time", key_class="fuel_and_battery", device_class=SensorDeviceClass.TIMESTAMP, ), "charging_status": BMWSensorEntityDescription( key="charging_status", - name="Charging status", + translation_key="charging_status", key_class="fuel_and_battery", icon="mdi:ev-station", value=lambda x, y: x.value, ), "charging_target": BMWSensorEntityDescription( key="charging_target", - name="Charging target", + translation_key="charging_target", key_class="fuel_and_battery", icon="mdi:battery-charging-high", unit_type=PERCENTAGE, ), "remaining_battery_percent": BMWSensorEntityDescription( key="remaining_battery_percent", - name="Remaining battery percent", + translation_key="remaining_battery_percent", key_class="fuel_and_battery", unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -98,14 +98,14 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { # --- Specific --- "mileage": BMWSensorEntityDescription( key="mileage", - name="Mileage", + translation_key="mileage", icon="mdi:speedometer", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_range_total": BMWSensorEntityDescription( key="remaining_range_total", - name="Remaining range total", + translation_key="remaining_range_total", key_class="fuel_and_battery", icon="mdi:map-marker-distance", unit_type=LENGTH, @@ -113,7 +113,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "remaining_range_electric": BMWSensorEntityDescription( key="remaining_range_electric", - name="Remaining range electric", + translation_key="remaining_range_electric", key_class="fuel_and_battery", icon="mdi:map-marker-distance", unit_type=LENGTH, @@ -121,7 +121,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "remaining_range_fuel": BMWSensorEntityDescription( key="remaining_range_fuel", - name="Remaining range fuel", + translation_key="remaining_range_fuel", key_class="fuel_and_battery", icon="mdi:map-marker-distance", unit_type=LENGTH, @@ -129,7 +129,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "remaining_fuel": BMWSensorEntityDescription( key="remaining_fuel", - name="Remaining fuel", + translation_key="remaining_fuel", key_class="fuel_and_battery", icon="mdi:gas-station", unit_type=VOLUME, @@ -137,7 +137,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { ), "remaining_fuel_percent": BMWSensorEntityDescription( key="remaining_fuel_percent", - name="Remaining fuel percent", + translation_key="remaining_fuel_percent", key_class="fuel_and_battery", icon="mdi:gas-station", unit_type=PERCENTAGE, diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 506175becd9..af73417b1a9 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -26,5 +26,114 @@ } } } + }, + "entity": { + "binary_sensor": { + "lids": { + "name": "Lids" + }, + "windows": { + "name": "Windows" + }, + "door_lock_state": { + "name": "Door lock state" + }, + "condition_based_services": { + "name": "Condition based services" + }, + "check_control_messages": { + "name": "Check control messages" + }, + "charging_status": { + "name": "Charging status" + }, + "connection_status": { + "name": "Connection status" + }, + "is_pre_entry_climatization_enabled": { + "name": "Pre entry climatization" + } + }, + "button": { + "light_flash": { + "name": "Flash lights" + }, + "sound_horn": { + "name": "Sound horn" + }, + "activate_air_conditioning": { + "name": "Activate air conditioning" + }, + "find_vehicle": { + "name": "Find vehicle" + }, + "refresh": { + "name": "Refresh from cloud" + } + }, + "lock": { + "lock": { + "name": "[%key:component::lock::title%]" + } + }, + "number": { + "target_soc": { + "name": "Target SoC" + } + }, + "select": { + "ac_limit": { + "name": "AC Charging Limit" + }, + "charging_mode": { + "name": "Charging Mode" + } + }, + "sensor": { + "ac_current_limit": { + "name": "AC current limit" + }, + "charging_start_time": { + "name": "Charging start time" + }, + "charging_end_time": { + "name": "Charging end time" + }, + "charging_status": { + "name": "Charging status" + }, + "charging_target": { + "name": "Charging target" + }, + "remaining_battery_percent": { + "name": "Remaining battery percent" + }, + "mileage": { + "name": "Mileage" + }, + "remaining_range_total": { + "name": "Remaining range total" + }, + "remaining_range_electric": { + "name": "Remaining range electric" + }, + "remaining_range_fuel": { + "name": "Remaining range fuel" + }, + "remaining_fuel": { + "name": "Remaining fuel" + }, + "remaining_fuel_percent": { + "name": "Remaining fuel percent" + } + }, + "switch": { + "climate": { + "name": "Climate" + }, + "charging": { + "name": "Charging" + } + } } } diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index 41243ca9323..298338dc9fa 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -51,7 +51,7 @@ CHARGING_STATE_ON = { NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ BMWSwitchEntityDescription( key="climate", - name="Climate", + translation_key="climate", is_available=lambda v: v.is_remote_climate_stop_enabled, value_fn=lambda v: v.climate.is_climate_on, remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), @@ -60,7 +60,7 @@ NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ ), BMWSwitchEntityDescription( key="charging", - name="Charging", + translation_key="charging", is_available=lambda v: v.is_remote_charge_stop_enabled, value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON, remote_service_on=lambda v: v.remote_services.trigger_charge_start(), diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 32b76c6fcae..1109cf0d311 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -35,6 +35,10 @@ class BondButtonEntityDescription( ): """Class to describe a Bond Button entity.""" + # BondEntity does not support UNDEFINED, + # restrict the type to str | None + name: str | None = None + STOP_BUTTON = BondButtonEntityDescription( key=Action.STOP, diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index 90558936592..9fd1055dd60 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_shc", "iot_class": "local_push", "loggers": ["boschshcpy"], - "requirements": ["boschshcpy==0.2.35"], + "requirements": ["boschshcpy==0.2.57"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index b310799323a..73307d9ea0a 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -117,7 +117,7 @@ async def async_setup_entry( ) for sensor in ( - session.device_helper.smart_plugs + session.device_helper.light_switches + session.device_helper.smart_plugs + session.device_helper.light_switches_bsm ): entities.append( PowerSensor( diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 6fe06213d75..3b3b6e2ffd4 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -111,7 +111,7 @@ async def async_setup_entry( ) ) - for switch in session.device_helper.light_switches: + for switch in session.device_helper.light_switches_bsm: entities.append( SHCSwitch( device=switch, diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index ff5691f9aed..cfa388fcce7 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -40,6 +40,7 @@ async def async_setup_entry( class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): """Representation of a Bravia TV Media Player.""" + _attr_name = None _attr_assumed_state = True _attr_device_class = MediaPlayerDeviceClass.TV _attr_supported_features = ( diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py index f45b2d74004..f9e3f464dcb 100644 --- a/homeassistant/components/braviatv/remote.py +++ b/homeassistant/components/braviatv/remote.py @@ -30,6 +30,8 @@ async def async_setup_entry( class BraviaTVRemote(BraviaTVEntity, RemoteEntity): """Representation of a Bravia TV Remote.""" + _attr_name = None + @property def is_on(self) -> bool: """Return true if device is on.""" diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 3b1312a64c5..e6a769fd2c4 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -5,12 +5,15 @@ from dataclasses import dataclass, field from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .device import BroadlinkDevice from .heartbeat import BroadlinkHeartbeat +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + @dataclass class BroadlinkData: diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index c116a1bb635..c0fb80971ca 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -107,6 +107,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): """Representation of a Broadlink remote.""" _attr_has_entity_name = True + _attr_name = None def __init__(self, device, codes, flags): """Initialize the remote.""" diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 50c58d41667..747418e1e79 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -25,18 +25,16 @@ from .entity import BroadlinkEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", - translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="air_quality", - translation_key="air_quality", + device_class=SensorDeviceClass.AQI, ), SensorEntityDescription( key="humidity", - translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -51,21 +49,18 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="power", - translation_key="power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="volt", - translation_key="voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current", - translation_key="current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index adff2303c74..87567bcb7b1 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -46,30 +46,12 @@ }, "entity": { "sensor": { - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "air_quality": { - "name": "[%key:component::sensor::entity_component::aqi::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "light": { "name": "[%key:component::sensor::entity_component::illuminance::name%]" }, "noise": { "name": "Noise" }, - "power": { - "name": "[%key:component::sensor::entity_component::power::name%]" - }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - }, - "current": { - "name": "[%key:component::sensor::entity_component::current::name%]" - }, "overload": { "name": "Overload" }, diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 009536a9adb..b8744865898 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -221,6 +221,7 @@ class BroadlinkSP2Switch(BroadlinkSP1Switch): _attr_assumed_state = False _attr_has_entity_name = True + _attr_name = None def __init__(self, device, *args, **kwargs): """Initialize the switch.""" diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index ca6173d2ef5..5512bcd1176 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -47,7 +47,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.7.0", + breaks_in_ha_version="2023.11.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", @@ -83,6 +83,7 @@ class BrottsplatskartanSensor(SensorEntity): _attr_attribution = ATTRIBUTION _attr_has_entity_name = True + _attr_name = None def __init__(self, bpk: BrottsplatsKartan, name: str, entry_id: str) -> None: """Initialize the Brottsplatskartan sensor.""" diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index cbc6dd00471..dc403611da2 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -23,6 +23,7 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util.enum import try_parse_enum from . import HomeAssistantBSBLANData from .const import ATTR_TARGET_TEMPERATURE, DOMAIN, LOGGER @@ -70,6 +71,7 @@ class BSBLANClimate( """Defines a BSBLAN climate device.""" _attr_has_entity_name = True + _attr_name = None # Determine preset modes _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE @@ -112,12 +114,11 @@ class BSBLANClimate( return float(self.coordinator.data.target_temperature.value) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" if self.coordinator.data.hvac_mode.value == PRESET_ECO: return HVACMode.AUTO - - return self.coordinator.data.hvac_mode.value + return try_parse_enum(HVACMode, self.coordinator.data.hvac_mode.value) @property def preset_mode(self) -> str | None: diff --git a/homeassistant/components/bt_smarthub/manifest.json b/homeassistant/components/bt_smarthub/manifest.json index 90f5d92a0a2..8f2dc631e80 100644 --- a/homeassistant/components/bt_smarthub/manifest.json +++ b/homeassistant/components/bt_smarthub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/bt_smarthub", "iot_class": "local_polling", "loggers": ["btsmarthub_devicelist"], - "requirements": ["btsmarthub_devicelist==0.2.3"] + "requirements": ["btsmarthub-devicelist==0.2.3"] } diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 99fe02f7a9d..0e2790a2e85 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -35,6 +35,7 @@ _LOGGER = logging.getLogger(__name__) class ButtonDeviceClass(StrEnum): """Device class for buttons.""" + IDENTIFY = "identify" RESTART = "restart" UPDATE = "update" @@ -88,6 +89,13 @@ class ButtonEntity(RestoreEntity): _attr_state: None = None __last_pressed: datetime | None = None + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For buttons this is True if the entity has a device class. + """ + return self.device_class is not None + @property def device_class(self) -> ButtonDeviceClass | None: """Return the class of this entity.""" diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py index 8398b4990cd..338b11e765b 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -19,14 +20,21 @@ from .const import DOMAIN, SERVICE_PRESS ACTION_TYPES = {"press"} -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ) +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( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -36,7 +44,7 @@ async def async_get_actions( { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "press", } for entry in er.async_entries_for_device(registry, device_id) diff --git a/homeassistant/components/button/device_trigger.py b/homeassistant/components/button/device_trigger.py index fbf054996c3..1b206337f33 100644 --- a/homeassistant/components/button/device_trigger.py +++ b/homeassistant/components/button/device_trigger.py @@ -26,7 +26,7 @@ TRIGGER_TYPES = {"pressed"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), } ) @@ -42,7 +42,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "pressed", } for entry in er.async_entries_for_device(registry, device_id) diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index efad77f5c6d..006959d1b4c 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -12,6 +12,9 @@ "_": { "name": "[%key:component::button::title%]" }, + "identify": { + "name": "Identify" + }, "restart": { "name": "Restart" }, diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 2cb807169ea..d56b2b0ddfa 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -8,7 +8,7 @@ from http import HTTPStatus from itertools import groupby import logging import re -from typing import Any, cast, final +from typing import Any, Final, cast, final from aiohttp import web from dateutil.rrule import rrulestr @@ -19,7 +19,12 @@ from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPOR from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -32,10 +37,12 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonValueType from .const import ( CONF_EVENT, EVENT_DESCRIPTION, + EVENT_DURATION, EVENT_END, EVENT_END_DATE, EVENT_END_DATETIME, @@ -53,6 +60,7 @@ from .const import ( EVENT_TIME_FIELDS, EVENT_TYPES, EVENT_UID, + LIST_EVENT_FIELDS, CalendarEntityFeature, ) @@ -250,6 +258,21 @@ CALENDAR_EVENT_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +SERVICE_LIST_EVENTS: Final = "list_events" +SERVICE_LIST_EVENTS_SCHEMA: Final = vol.All( + cv.has_at_least_one_key(EVENT_END_DATETIME, EVENT_DURATION), + cv.has_at_most_one_key(EVENT_END_DATETIME, EVENT_DURATION), + cv.make_entity_service_schema( + { + vol.Optional(EVENT_START_DATETIME): cv.datetime, + vol.Optional(EVENT_END_DATETIME): cv.datetime, + vol.Optional(EVENT_DURATION): vol.All( + cv.time_period, cv.positive_timedelta + ), + } + ), +) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for calendars.""" @@ -274,7 +297,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_create_event, required_features=[CalendarEntityFeature.CREATE_EVENT], ) - + component.async_register_entity_service( + SERVICE_LIST_EVENTS, + SERVICE_LIST_EVENTS_SCHEMA, + async_list_events_service, + supports_response=SupportsResponse.ONLY, + ) await component.async_setup(config) return True @@ -388,6 +416,17 @@ def _api_event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, Any]: return result +def _list_events_dict_factory( + obj: Iterable[tuple[str, Any]] +) -> dict[str, JsonValueType]: + """Convert CalendarEvent dataclass items to dictionary of attributes.""" + return { + name: value + for name, value in _event_dict_factory(obj).items() + if name in LIST_EVENT_FIELDS and value is not None + } + + def _get_datetime_local( dt_or_d: datetime.datetime | datetime.date, ) -> datetime.datetime: @@ -743,3 +782,23 @@ async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None: EVENT_END: end, } await entity.async_create_event(**params) + + +async def async_list_events_service( + calendar: CalendarEntity, service_call: ServiceCall +) -> ServiceResponse: + """List events on a calendar during a time drange.""" + 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] + else: + end = service_call.data[EVENT_END_DATETIME] + calendar_event_list = await calendar.async_get_events( + calendar.hass, dt_util.as_local(start), dt_util.as_local(end) + ) + return { + "events": [ + dataclasses.asdict(event, dict_factory=_list_events_dict_factory) + for event in calendar_event_list + ] + } diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index 3fbab6742a9..e667510325b 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -40,3 +40,13 @@ EVENT_TIME_FIELDS = { EVENT_IN, } EVENT_TYPES = "event_types" +EVENT_DURATION = "duration" + +# Fields for the list events service +LIST_EVENT_FIELDS = { + "start", + "end", + EVENT_SUMMARY, + EVENT_DESCRIPTION, + EVENT_LOCATION, +} diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 5d1a3ccf0f4..af69882bba5 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -52,3 +52,27 @@ create_event: example: "Conference Room - F123, Bldg. 002" selector: text: +list_events: + name: List event + description: List events on a calendar within a time range. + target: + entity: + domain: calendar + fields: + start_date_time: + name: Start time + description: Return active events after this time (exclusive). When not set, defaults to now. + example: "2022-03-22 20:00:00" + selector: + datetime: + end_date_time: + name: End time + description: Return active events before this time (exclusive). Cannot be used with 'duration'. + example: "2022-03-22 22:00:00" + selector: + datetime: + duration: + name: Duration + description: Return active events from start_date_time until the specified duration. + selector: + duration: diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 48921303ce0..7cf318f12a6 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["pychromecast==13.0.7"], + "requirements": ["PyChromecast==13.0.7"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 3031eb8365b..d32ff07c261 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -267,6 +267,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): """Representation of a Cast device on the network.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False _attr_media_image_remotely_accessible = True _mz_only = False @@ -361,15 +362,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): ): external_url = None internal_url = None - tts_base_url = None url_description = "" - if "tts" in self.hass.config.components: - # pylint: disable-next=[import-outside-toplevel] - from homeassistant.components import tts - - with suppress(KeyError): # base_url not configured - tts_base_url = tts.get_base_url(self.hass) - with suppress(NoURLAvailableError): # external_url not configured external_url = get_url(self.hass, allow_internal=False) @@ -377,8 +370,6 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): internal_url = get_url(self.hass, allow_external=False) if media_status.content_id: - if tts_base_url and media_status.content_id.startswith(tts_base_url): - url_description = f" from tts.base_url ({tts_base_url})" if external_url and media_status.content_id.startswith(external_url): url_description = f" from external_url ({external_url})" if internal_url and media_status.content_id.startswith(internal_url): diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 5f6152b7bc7..4fc89bc918b 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -8,10 +8,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, - EVENT_HOMEASSISTANT_STARTED, Platform, ) -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import HomeAssistant +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_PORT, DOMAIN @@ -38,19 +38,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=f"{host}:{port}") - async def async_finish_startup(_): + async def _async_finish_startup(_): await coordinator.async_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - if hass.state == CoreState.running: - await async_finish_startup(None) - else: - entry.async_on_unload( - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, async_finish_startup - ) - ) - + async_at_started(hass, _async_finish_startup) return True diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index 582c6118f57..0817025c703 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -1,4 +1,5 @@ """Helper functions for the Cert Expiry platform.""" +from functools import cache import socket import ssl @@ -14,12 +15,18 @@ from .errors import ( ) +@cache +def _get_default_ssl_context(): + """Return the default SSL context.""" + return ssl.create_default_context() + + def get_cert( host: str, port: int, ): """Get the certificate for the host and port combination.""" - ctx = ssl.create_default_context() + ctx = _get_default_ssl_context() address = (host, port) with socket.create_connection(address, timeout=TIMEOUT) as sock, ctx.wrap_socket( sock, server_hostname=address[0] diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index ed0f8f2a4aa..e62cb1143b5 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -214,9 +214,9 @@ class ClimateEntity(Entity): _attr_current_temperature: float | None = None _attr_fan_mode: str | None _attr_fan_modes: list[str] | None - _attr_hvac_action: HVACAction | str | None = None - _attr_hvac_mode: HVACMode | str | None - _attr_hvac_modes: list[HVACMode] | list[str] + _attr_hvac_action: HVACAction | None = None + _attr_hvac_mode: HVACMode | None + _attr_hvac_modes: list[HVACMode] _attr_is_aux_heat: bool | None _attr_max_humidity: int = DEFAULT_MAX_HUMIDITY _attr_max_temp: float @@ -361,17 +361,17 @@ class ClimateEntity(Entity): return self._attr_target_humidity @property - def hvac_mode(self) -> HVACMode | str | None: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" return self._attr_hvac_mode @property - def hvac_modes(self) -> list[HVACMode] | list[str]: + def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" return self._attr_hvac_modes @property - def hvac_action(self) -> HVACAction | str | None: + def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" return self._attr_hvac_action @@ -534,6 +534,14 @@ class ClimateEntity(Entity): await self.hass.async_add_executor_job(self.turn_on) return + # If there are only two HVAC modes, and one of those modes is OFF, + # then we can just turn on the other mode. + if len(self.hvac_modes) == 2 and HVACMode.OFF in self.hvac_modes: + for mode in self.hvac_modes: + if mode != HVACMode.OFF: + await self.async_set_hvac_mode(mode) + return + # Fake turn on for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL): if mode not in self.hvac_modes: diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 9ee561b9c1b..41d4646aeae 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -96,6 +96,7 @@ class HVACAction(StrEnum): HEATING = "heating" IDLE = "idle" OFF = "off" + PREHEATING = "preheating" # These CURRENT_HVAC_* constants are deprecated as of Home Assistant 2022.5. diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 0119ad65801..6714e0bf35a 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -3,6 +3,10 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, + async_validate_entity_schema, +) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -24,7 +28,7 @@ ACTION_TYPES = {"set_hvac_mode", "set_preset_mode"} SET_HVAC_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): "set_hvac_mode", - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(const.ATTR_HVAC_MODE): vol.In(const.HVAC_MODES), } ) @@ -32,12 +36,19 @@ SET_HVAC_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( SET_PRESET_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): "set_preset_mode", - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(const.ATTR_PRESET_MODE): str, } ) -ACTION_SCHEMA = vol.Any(SET_HVAC_MODE_SCHEMA, SET_PRESET_MODE_SCHEMA) +_ACTION_SCHEMA = vol.Any(SET_HVAC_MODE_SCHEMA, SET_PRESET_MODE_SCHEMA) + + +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( @@ -57,7 +68,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } actions.append({**base_action, CONF_TYPE: "set_hvac_mode"}) @@ -93,23 +104,24 @@ async def async_get_action_capabilities( ) -> dict[str, vol.Schema]: """List action capabilities.""" action_type = config[CONF_TYPE] + entity_id_or_uuid = config[CONF_ENTITY_ID] fields = {} if action_type == "set_hvac_mode": try: + entry = async_get_entity_registry_entry_or_raise(hass, entity_id_or_uuid) hvac_modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_HVAC_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_HVAC_MODES) or [] ) except HomeAssistantError: hvac_modes = [] fields[vol.Required(const.ATTR_HVAC_MODE)] = vol.In(hvac_modes) elif action_type == "set_preset_mode": try: + entry = async_get_entity_registry_entry_or_raise(hass, entity_id_or_uuid) preset_modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_PRESET_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_PRESET_MODES) or [] ) except HomeAssistantError: preset_modes = [] diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 97dc27cfa09..d9f1b240a9a 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -3,6 +3,9 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, +) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_CONDITION, @@ -28,7 +31,7 @@ CONDITION_TYPES = {"is_hvac_mode", "is_preset_mode"} HVAC_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "is_hvac_mode", vol.Required(const.ATTR_HVAC_MODE): vol.In(const.HVAC_MODES), } @@ -36,7 +39,7 @@ HVAC_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( PRESET_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "is_preset_mode", vol.Required(const.ATTR_PRESET_MODE): str, } @@ -63,7 +66,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions.append({**base_condition, CONF_TYPE: "is_hvac_mode"}) @@ -80,9 +83,12 @@ def async_condition_from_config( ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - if (state := hass.states.get(config[ATTR_ENTITY_ID])) is None: + if not entity_id or (state := hass.states.get(entity_id)) is None: return False if config[CONF_TYPE] == "is_hvac_mode": @@ -106,9 +112,11 @@ async def async_get_condition_capabilities( if condition_type == "is_hvac_mode": try: + entry = async_get_entity_registry_entry_or_raise( + hass, config[CONF_ENTITY_ID] + ) hvac_modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_HVAC_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_HVAC_MODES) or [] ) except HomeAssistantError: hvac_modes = [] @@ -116,9 +124,11 @@ async def async_get_condition_capabilities( elif condition_type == "is_preset_mode": try: + entry = async_get_entity_registry_entry_or_raise( + hass, config[CONF_ENTITY_ID] + ) preset_modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_PRESET_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_PRESET_MODES) or [] ) except HomeAssistantError: preset_modes = [] diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 005e744b53f..0afd2485517 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -34,7 +34,7 @@ TRIGGER_TYPES = { HVAC_MODE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "hvac_mode_changed", vol.Required(state_trigger.CONF_TO): vol.In(const.HVAC_MODES), } @@ -43,7 +43,7 @@ HVAC_MODE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( CURRENT_TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( ["current_temperature_changed", "current_humidity_changed"] ), @@ -77,7 +77,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } triggers.append( @@ -142,7 +142,7 @@ async def async_attach_trigger( numeric_state_config[ numeric_state_trigger.CONF_VALUE_TEMPLATE ] = "{{ state.attributes.current_temperature }}" - else: + else: # trigger_type == "current_humidity_changed" numeric_state_config[ numeric_state_trigger.CONF_VALUE_TEMPLATE ] = "{{ state.attributes.current_humidity }}" diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 00696b0738c..73ac4d6fbc4 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -54,6 +54,7 @@ "name": "Current action", "state": { "off": "Off", + "preheating": "Preheating", "heating": "Heating", "cooling": "Cooling", "drying": "Drying", diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 620e650315a..40e5f264caf 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -234,7 +234,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websession = async_get_clientsession(hass) client = CloudClient(hass, prefs, websession, alexa_conf, google_conf) cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) - cloud.iot.register_on_connect(client.on_cloud_connected) async def _shutdown(event: Event) -> None: """Shutdown event.""" diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index dff3bdcdbdd..236635a0bb8 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -17,6 +17,7 @@ from homeassistant.components.alexa import ( smart_home as alexa_smart_home, ) from homeassistant.components.google_assistant import smart_home as ga +from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -135,7 +136,7 @@ class CloudClient(Interface): return self._google_config - async def on_cloud_connected(self) -> None: + async def cloud_connected(self) -> None: """When cloud is connected.""" is_new_user = await self.prefs.async_set_username(self.cloud.username) @@ -182,6 +183,9 @@ class CloudClient(Interface): if tasks: await asyncio.gather(*(task(None) for task in tasks)) + async def cloud_disconnected(self) -> None: + """When cloud disconnected.""" + async def cloud_started(self) -> None: """When cloud is started.""" @@ -209,6 +213,19 @@ class CloudClient(Interface): """Process cloud remote message to client.""" await self._prefs.async_update(remote_enabled=connect) + async def async_cloud_connection_info( + self, payload: dict[str, Any] + ) -> dict[str, Any]: + """Process cloud connection info message to client.""" + return { + "remote": { + "connected": self.cloud.remote.is_connected, + "enabled": self._prefs.remote_enabled, + "instance_domain": self.cloud.remote.instance_domain, + }, + "version": HA_VERSION, + } + async def async_alexa_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: """Process cloud alexa message to client.""" cloud_user = await self._prefs.get_cloud_user() diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index d352b7226f0..d8fd2148b4d 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.67.1"] + "requirements": ["hass-nabucasa==0.69.0"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 46ddafd48e7..8b6f773e5d9 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -47,7 +47,7 @@ GOOGLE_SETTINGS_VERSION = 3 class CloudPreferencesStore(Store): - """Store entity registry data.""" + """Store cloud preferences.""" async def _async_migrate_func( self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 801718b88a7..b4dc01d03aa 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/co2signal", "iot_class": "cloud_polling", "loggers": ["CO2Signal"], - "requirements": ["co2signal==0.4.2"] + "requirements": ["CO2Signal==0.4.2"] } diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index aa2d9cfbfdb..d0a6b53964b 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -3,10 +3,10 @@ import asyncio import io import logging -from PIL import UnidentifiedImageError import aiohttp import async_timeout from colorthief import ColorThief +from PIL import UnidentifiedImageError import voluptuous as vol from homeassistant.components.light import ( diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index e7007b24592..f2097178a95 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -29,6 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -69,7 +70,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml_binary_sensor", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_platform_yaml", @@ -95,23 +96,27 @@ async def async_setup_platform( value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) + trigger_entity_config = { + CONF_UNIQUE_ID: unique_id, + CONF_NAME: Template(name, hass), + CONF_DEVICE_CLASS: device_class, + } + async_add_entities( [ CommandBinarySensor( data, - name, - device_class, + trigger_entity_config, payload_on, payload_off, value_template, - unique_id, scan_interval, ) ], ) -class CommandBinarySensor(BinarySensorEntity): +class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): """Representation of a command line binary sensor.""" _attr_should_poll = False @@ -119,23 +124,19 @@ class CommandBinarySensor(BinarySensorEntity): def __init__( self, data: CommandSensorData, - name: str, - device_class: BinarySensorDeviceClass | None, + config: ConfigType, payload_on: str, payload_off: str, value_template: Template | None, - unique_id: str | None, scan_interval: timedelta, ) -> None: """Initialize the Command line binary sensor.""" + super().__init__(self.hass, config) self.data = data - self._attr_name = name - self._attr_device_class = device_class self._attr_is_on = None self._payload_on = payload_on self._payload_off = payload_off self._value_template = value_template - self._attr_unique_id = unique_id self._scan_interval = scan_interval self._process_updates: asyncio.Lock | None = None @@ -183,6 +184,7 @@ class CommandBinarySensor(BinarySensorEntity): elif value == self._payload_off: self._attr_is_on = False + self._process_manual_data(value) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 041fa122f37..553af2f0c86 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -30,6 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -72,7 +73,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml_cover", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_platform_yaml", @@ -90,16 +91,20 @@ async def async_setup_platform( ): # Backward compatibility. Can be removed after deprecation device_config[CONF_NAME] = name + trigger_entity_config = { + CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), + CONF_NAME: Template(device_config.get(CONF_NAME, device_name), hass), + } + covers.append( CommandCover( - device_config.get(CONF_NAME, device_name), + trigger_entity_config, device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], device_config[CONF_COMMAND_STOP], device_config.get(CONF_COMMAND_STATE), value_template, device_config[CONF_COMMAND_TIMEOUT], - device_config.get(CONF_UNIQUE_ID), device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) @@ -111,25 +116,24 @@ async def async_setup_platform( async_add_entities(covers) -class CommandCover(CoverEntity): +class CommandCover(ManualTriggerEntity, CoverEntity): """Representation a command line cover.""" _attr_should_poll = False def __init__( self, - name: str, + config: ConfigType, command_open: str, command_close: str, command_stop: str, command_state: str | None, value_template: Template | None, timeout: int, - unique_id: str | None, scan_interval: timedelta, ) -> None: """Initialize the cover.""" - self._attr_name = name + super().__init__(self.hass, config) self._state: int | None = None self._command_open = command_open self._command_close = command_close @@ -137,7 +141,6 @@ class CommandCover(CoverEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout - self._attr_unique_id = unique_id self._scan_interval = scan_interval self._process_updates: asyncio.Lock | None = None @@ -218,7 +221,8 @@ class CommandCover(CoverEntity): self._state = None if payload: self._state = int(payload) - await self.async_update_ha_state(True) + self._process_manual_data(payload) + self.async_write_ha_state() async def async_update(self) -> None: """Update the entity. diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 2922b8caae3..d00926eb0ee 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -43,7 +43,7 @@ def get_service( hass, DOMAIN, "deprecated_yaml_notify", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_platform_yaml", @@ -72,7 +72,7 @@ class CommandLineNotificationService(BaseNotificationService): universal_newlines=True, stdin=subprocess.PIPE, close_fds=False, # required for posix_spawn - shell=True, # nosec # shell by design + shell=True, # noqa: S602 # shell by design ) as proc: try: proc.communicate(input=message, timeout=self._timeout) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 8137173d613..1b865827e69 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Mapping from datetime import timedelta import json +from typing import Any, cast import voluptuous as vol @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, SensorEntity, + SensorStateClass, ) from homeassistant.const import ( CONF_COMMAND, @@ -32,6 +34,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -71,7 +74,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml_sensor", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_platform_yaml", @@ -90,24 +93,31 @@ async def async_setup_platform( value_template.hass = hass json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + state_class: SensorStateClass | None = sensor_config.get(CONF_STATE_CLASS) data = CommandSensorData(hass, command, command_timeout) + trigger_entity_config = { + CONF_UNIQUE_ID: unique_id, + CONF_NAME: Template(name, hass), + CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), + } + async_add_entities( [ CommandSensor( data, - name, + trigger_entity_config, unit, + state_class, value_template, json_attributes, - unique_id, scan_interval, ) ] ) -class CommandSensor(SensorEntity): +class CommandSensor(ManualTriggerEntity, SensorEntity): """Representation of a sensor that is using shell commands.""" _attr_should_poll = False @@ -115,25 +125,30 @@ class CommandSensor(SensorEntity): def __init__( self, data: CommandSensorData, - name: str, + config: ConfigType, unit_of_measurement: str | None, + state_class: SensorStateClass | None, value_template: Template | None, json_attributes: list[str] | None, - unique_id: str | None, scan_interval: timedelta, ) -> None: """Initialize the sensor.""" - self._attr_name = name + super().__init__(self.hass, config) self.data = data self._attr_extra_state_attributes = {} self._json_attributes = json_attributes self._attr_native_value = None self._value_template = value_template self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_unique_id = unique_id + self._attr_state_class = state_class self._scan_interval = scan_interval self._process_updates: asyncio.Lock | None = None + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra state attributes.""" + return cast(dict, self._attr_extra_state_attributes) + async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() @@ -187,6 +202,7 @@ class CommandSensor(SensorEntity): LOGGER.warning("Empty reply found when expecting JSON data") if self._value_template is None: self._attr_native_value = None + self._process_manual_data(value) return if self._value_template is not None: @@ -198,7 +214,7 @@ class CommandSensor(SensorEntity): ) else: self._attr_native_value = value - + self._process_manual_data(value) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 4a33d8072d7..8fbafd7a4d1 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -74,7 +74,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml_switch", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_platform_yaml", @@ -238,7 +238,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if payload or value: self._attr_is_on = (value or payload).lower() == "true" self._process_manual_data(payload) - await self.async_update_ha_state(True) + self.async_write_ha_state() async def async_update(self) -> None: """Update the entity. diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index 2d42732190e..66faa3a0bf8 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -19,7 +19,7 @@ def call_shell_with_timeout( _LOGGER.debug("Running command: %s", command) subprocess.check_output( command, - shell=True, # nosec # shell by design + shell=True, # noqa: S602 # shell by design timeout=timeout, close_fds=False, # required for posix_spawn ) @@ -45,7 +45,7 @@ def check_output_or_log(command: str, timeout: int) -> str | None: try: return_value = subprocess.check_output( command, - shell=True, # nosec # shell by design + shell=True, # noqa: S602 # shell by design timeout=timeout, close_fds=False, # required for posix_spawn ) diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index e36737c7d35..01003020108 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -1,5 +1,6 @@ """The Compensation integration.""" import logging +from operator import itemgetter import numpy as np import voluptuous as vol @@ -7,6 +8,8 @@ import voluptuous as vol from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( CONF_ATTRIBUTE, + CONF_MAXIMUM, + CONF_MINIMUM, CONF_SOURCE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -20,8 +23,10 @@ from .const import ( CONF_COMPENSATION, CONF_DATAPOINTS, CONF_DEGREE, + CONF_LOWER_LIMIT, CONF_POLYNOMIAL, CONF_PRECISION, + CONF_UPPER_LIMIT, DATA_COMPENSATION, DEFAULT_DEGREE, DEFAULT_PRECISION, @@ -50,6 +55,8 @@ COMPENSATION_SCHEMA = vol.Schema( ], vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean, + vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int, vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): vol.All( vol.Coerce(int), @@ -78,8 +85,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: degree = conf[CONF_DEGREE] + initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS] + sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0)) + # get x values and y values from the x,y point pairs - x_values, y_values = zip(*conf[CONF_DATAPOINTS]) + x_values, y_values = zip(*initial_coefficients) # try to get valid coefficients for a polynomial coefficients = None @@ -99,6 +109,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } data[CONF_POLYNOMIAL] = np.poly1d(coefficients) + if data[CONF_LOWER_LIMIT]: + data[CONF_MINIMUM] = sorted_coefficients[0] + else: + data[CONF_MINIMUM] = None + + if data[CONF_UPPER_LIMIT]: + data[CONF_MAXIMUM] = sorted_coefficients[-1] + else: + data[CONF_MAXIMUM] = None + hass.data[DATA_COMPENSATION][compensation] = data hass.async_create_task( diff --git a/homeassistant/components/compensation/const.py b/homeassistant/components/compensation/const.py index f116725883e..d49a6982166 100644 --- a/homeassistant/components/compensation/const.py +++ b/homeassistant/components/compensation/const.py @@ -4,6 +4,8 @@ DOMAIN = "compensation" SENSOR = "compensation" CONF_COMPENSATION = "compensation" +CONF_LOWER_LIMIT = "lower_limit" +CONF_UPPER_LIMIT = "upper_limit" CONF_DATAPOINTS = "data_points" CONF_DEGREE = "degree" CONF_PRECISION = "precision" diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 16226974120..4d6ff95b810 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -10,6 +10,8 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_ATTRIBUTE, + CONF_MAXIMUM, + CONF_MINIMUM, CONF_SOURCE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -64,6 +66,8 @@ async def async_setup_platform( conf[CONF_PRECISION], conf[CONF_POLYNOMIAL], conf.get(CONF_UNIT_OF_MEASUREMENT), + conf[CONF_MINIMUM], + conf[CONF_MAXIMUM], ) ] ) @@ -83,6 +87,8 @@ class CompensationSensor(SensorEntity): precision: int, polynomial: np.poly1d, unit_of_measurement: str | None, + minimum: tuple[float, float] | None, + maximum: tuple[float, float] | None, ) -> None: """Initialize the Compensation sensor.""" self._source_entity_id = source @@ -93,6 +99,8 @@ class CompensationSensor(SensorEntity): self._coefficients = polynomial.coefficients.tolist() self._attr_unique_id = unique_id self._attr_name = name + self._minimum = minimum + self._maximum = maximum async def async_added_to_hass(self) -> None: """Handle added to Hass.""" @@ -132,7 +140,14 @@ class CompensationSensor(SensorEntity): else: value = None if new_state.state == STATE_UNKNOWN else new_state.state try: - self._attr_native_value = round(self._poly(float(value)), self._precision) + x_value = float(value) + if self._minimum is not None and x_value <= self._minimum[0]: + y_value = self._minimum[1] + elif self._maximum is not None and x_value >= self._maximum[0]: + y_value = self._maximum[1] + else: + y_value = self._poly(x_value) + self._attr_native_value = round(y_value, self._precision) except (ValueError, TypeError): self._attr_native_value = None diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index fde9b00aba2..a2d1308be98 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) CONTROL4_CATEGORY = "lights" CONTROL4_NON_DIMMER_VAR = "LIGHT_STATE" -CONTROL4_DIMMER_VAR = "LIGHT_LEVEL" +CONTROL4_DIMMER_VARS = ["LIGHT_LEVEL", "Brightness Percent"] async def async_setup_entry( @@ -57,7 +57,7 @@ async def async_setup_entry( """Fetch data from Control4 director for dimmer lights.""" try: return await update_variables_for_config_entry( - hass, entry, {CONTROL4_DIMMER_VAR} + hass, entry, {*CONTROL4_DIMMER_VARS} ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err @@ -190,14 +190,19 @@ class Control4Light(Control4Entity, LightEntity): def is_on(self): """Return whether this light is on or off.""" if self._is_dimmer: - return self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] > 0 + for var in CONTROL4_DIMMER_VARS: + if var in self.coordinator.data[self._idx]: + return self.coordinator.data[self._idx][var] > 0 + raise RuntimeError("Dimmer Variable Not Found") return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0 @property def brightness(self): """Return the brightness of this light between 0..255.""" if self._is_dimmer: - return round(self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] * 2.55) + for var in CONTROL4_DIMMER_VARS: + if var in self.coordinator.data[self._idx]: + return round(self.coordinator.data[self._idx][var] * 2.55) return None @property diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index f156acfd568..5b82b5dae72 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -8,6 +8,7 @@ import logging import re from typing import Any, Literal +from hassil.recognize import RecognizeResult import voluptuous as vol from homeassistant import core @@ -16,6 +17,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry from homeassistant.const import MATCH_ALL from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent, singleton from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -154,12 +156,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if config_intents := config.get(DOMAIN, {}).get("intents"): hass.data[DATA_CONFIG] = config_intents - async def handle_process(service: core.ServiceCall) -> None: + async def handle_process(service: core.ServiceCall) -> core.ServiceResponse: """Parse text into commands.""" text = service.data[ATTR_TEXT] _LOGGER.debug("Processing: <%s>", text) try: - await async_converse( + result = await async_converse( hass=hass, text=text, conversation_id=None, @@ -168,7 +170,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: agent_id=service.data.get(ATTR_AGENT_ID), ) except intent.IntentHandleError as err: - _LOGGER.error("Error processing %s: %s", text, err) + raise HomeAssistantError(f"Error processing {text}: {err}") from err + + if service.return_response: + return result.as_dict() + + return None async def handle_reload(service: core.ServiceCall) -> None: """Reload intents.""" @@ -176,7 +183,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await agent.async_reload(language=service.data.get(ATTR_LANGUAGE)) hass.services.async_register( - DOMAIN, SERVICE_PROCESS, handle_process, schema=SERVICE_PROCESS_SCHEMA + DOMAIN, + SERVICE_PROCESS, + handle_process, + schema=SERVICE_PROCESS_SCHEMA, + supports_response=core.SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, SERVICE_RELOAD, handle_reload, schema=SERVICE_RELOAD_SCHEMA @@ -186,6 +197,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_prepare) websocket_api.async_register_command(hass, websocket_get_agent_info) websocket_api.async_register_command(hass, websocket_list_agents) + websocket_api.async_register_command(hass, websocket_hass_agent_debug) return True @@ -297,6 +309,107 @@ async def websocket_list_agents( connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents})) +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/agent/homeassistant/debug", + vol.Required("sentences"): [str], + vol.Optional("language"): str, + vol.Optional("device_id"): vol.Any(str, None), + } +) +@websocket_api.async_response +async def websocket_hass_agent_debug( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Return intents that would be matched by the default agent for a list of sentences.""" + agent = await _get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) + assert isinstance(agent, DefaultAgent) + results = [ + await agent.async_recognize( + ConversationInput( + text=sentence, + context=connection.context(msg), + conversation_id=None, + device_id=msg.get("device_id"), + language=msg.get("language", hass.config.language), + ) + ) + for sentence in msg["sentences"] + ] + + # Return results for each sentence in the same order as the input. + connection.send_result( + msg["id"], + { + "results": [ + { + "intent": { + "name": result.intent.name, + }, + "entities": { + entity_key: { + "name": entity.name, + "value": entity.value, + "text": entity.text, + } + for entity_key, entity in result.entities.items() + }, + "targets": { + state.entity_id: {"matched": is_matched} + for state, is_matched in _get_debug_targets(hass, result) + }, + } + if result is not None + else None + for result in results + ] + }, + ) + + +def _get_debug_targets( + hass: HomeAssistant, + result: RecognizeResult, +) -> Iterable[tuple[core.State, bool]]: + """Yield state/is_matched pairs for a hassil recognition.""" + entities = result.entities + + name: str | None = None + area_name: str | None = None + domains: set[str] | None = None + device_classes: set[str] | None = None + state_names: set[str] | None = None + + if "name" in entities: + name = str(entities["name"].value) + + if "area" in entities: + area_name = str(entities["area"].value) + + if "domain" in entities: + domains = set(cv.ensure_list(entities["domain"].value)) + + if "device_class" in entities: + device_classes = set(cv.ensure_list(entities["device_class"].value)) + + if "state" in entities: + # HassGetState only + state_names = set(cv.ensure_list(entities["state"].value)) + + states = intent.async_match_states( + hass, + name=name, + area_name=area_name, + domains=domains, + device_classes=device_classes, + ) + + for state in states: + # For queries, a target is "matched" based on its state + is_matched = (state_names is None) or (state.state in state_names) + yield state, is_matched + + class ConversationProcessView(http.HomeAssistantView): """View to process text.""" @@ -362,6 +475,7 @@ async def async_converse( context: core.Context, language: str | None = None, agent_id: str | None = None, + device_id: str | None = None, ) -> ConversationResult: """Process text and get intent.""" agent = await _get_agent_manager(hass).async_get_agent(agent_id) @@ -375,6 +489,7 @@ async def async_converse( text=text, context=context, conversation_id=conversation_id, + device_id=device_id, language=language, ) ) @@ -462,12 +577,8 @@ class AgentManager: def async_set_agent(self, agent_id: str, agent: AbstractConversationAgent) -> None: """Set the agent.""" self._agents[agent_id] = agent - if self.default_agent == HOME_ASSISTANT_AGENT: - self.default_agent = agent_id @core.callback def async_unset_agent(self, agent_id: str) -> None: """Unset the agent.""" - if self.default_agent == agent_id: - self.default_agent = HOME_ASSISTANT_AGENT self._agents.pop(agent_id, None) diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py index 162338a6ff0..99b9c9392d8 100644 --- a/homeassistant/components/conversation/agent.py +++ b/homeassistant/components/conversation/agent.py @@ -16,6 +16,7 @@ class ConversationInput: text: str context: Context conversation_id: str | None + device_id: str | None language: str diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 44b13522412..336d6287f18 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -3,8 +3,9 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass +import functools import logging from pathlib import Path import re @@ -42,6 +43,9 @@ _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) +TRIGGER_CALLBACK_TYPE = Callable[ # pylint: disable=invalid-name + [str], Awaitable[str | None] +] def json_load(fp: IO[str]) -> JsonObjectType: @@ -60,6 +64,14 @@ class LanguageIntents: loaded_components: set[str] +@dataclass(slots=True) +class TriggerData: + """List of sentences and the callback for a trigger.""" + + sentences: list[str] + callback: TRIGGER_CALLBACK_TYPE + + def _get_language_variations(language: str) -> Iterable[str]: """Generate language codes with and without region.""" yield language @@ -110,6 +122,10 @@ class DefaultAgent(AbstractConversationAgent): self._config_intents: dict[str, Any] = {} self._slot_lists: dict[str, SlotList] | None = None + # Sentences that will trigger a callback (skipping intent recognition) + self._trigger_sentences: list[TriggerData] = [] + self._trigger_intents: Intents | None = None + @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" @@ -143,11 +159,12 @@ class DefaultAgent(AbstractConversationAgent): self.hass, DOMAIN, self._async_exposed_entities_updated ) - async def async_process(self, user_input: ConversationInput) -> ConversationResult: - """Process a sentence.""" + async def async_recognize( + self, user_input: ConversationInput + ) -> RecognizeResult | None: + """Recognize intent from user input.""" language = user_input.language or self.hass.config.language lang_intents = self._lang_intents.get(language) - conversation_id = None # Not supported # Reload intents if missing or new components if lang_intents is None or ( @@ -159,21 +176,29 @@ class DefaultAgent(AbstractConversationAgent): if lang_intents is None: # No intents loaded _LOGGER.warning("No intents were loaded for language: %s", language) - return _make_error_result( - language, - intent.IntentResponseErrorCode.NO_INTENT_MATCH, - _DEFAULT_ERROR_TEXT, - conversation_id, - ) + return None slot_lists = self._make_slot_lists() - result = await self.hass.async_add_executor_job( self._recognize, user_input, lang_intents, slot_lists, ) + + return result + + async def async_process(self, user_input: ConversationInput) -> ConversationResult: + """Process a sentence.""" + if trigger_result := await self._match_triggers(user_input.text): + return trigger_result + + language = user_input.language or self.hass.config.language + conversation_id = None # Not supported + + result = await self.async_recognize(user_input) + lang_intents = self._lang_intents.get(language) + if result is None: _LOGGER.debug("No intent was matched for '%s'", user_input.text) return _make_error_result( @@ -183,6 +208,10 @@ class DefaultAgent(AbstractConversationAgent): conversation_id, ) + # Will never happen because result will be None when no intents are + # loaded in async_recognize. + assert lang_intents is not None + try: intent_response = await intent.async_handle( self.hass, @@ -585,13 +614,109 @@ class DefaultAgent(AbstractConversationAgent): return self._slot_lists def _get_error_text( - self, response_type: ResponseType, lang_intents: LanguageIntents + self, response_type: ResponseType, lang_intents: LanguageIntents | None ) -> str: """Get response error text by type.""" + if lang_intents is None: + return _DEFAULT_ERROR_TEXT + response_key = response_type.value response_str = lang_intents.error_responses.get(response_key) return response_str or _DEFAULT_ERROR_TEXT + def register_trigger( + self, + sentences: list[str], + callback: TRIGGER_CALLBACK_TYPE, + ) -> core.CALLBACK_TYPE: + """Register a list of sentences that will trigger a callback when recognized.""" + trigger_data = TriggerData(sentences=sentences, callback=callback) + self._trigger_sentences.append(trigger_data) + + # Force rebuild on next use + self._trigger_intents = None + + unregister = functools.partial(self._unregister_trigger, trigger_data) + return unregister + + def _rebuild_trigger_intents(self) -> None: + """Rebuild the HassIL intents object from the current trigger sentences.""" + intents_dict = { + "language": self.hass.config.language, + "intents": { + # Use trigger data index as a virtual intent name for HassIL. + # This works because the intents are rebuilt on every + # register/unregister. + str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]} + for trigger_id, trigger_data in enumerate(self._trigger_sentences) + }, + } + + self._trigger_intents = Intents.from_dict(intents_dict) + _LOGGER.debug("Rebuilt trigger intents: %s", intents_dict) + + def _unregister_trigger(self, trigger_data: TriggerData) -> None: + """Unregister a set of trigger sentences.""" + self._trigger_sentences.remove(trigger_data) + + # Force rebuild on next use + self._trigger_intents = None + + async def _match_triggers(self, sentence: str) -> ConversationResult | None: + """Try to match sentence against registered trigger sentences. + + Calls the registered callbacks if there's a match and returns a positive + conversation result. + """ + if not self._trigger_sentences: + # No triggers registered + return None + + if self._trigger_intents is None: + # Need to rebuild intents before matching + self._rebuild_trigger_intents() + + assert self._trigger_intents is not None + + matched_triggers: set[int] = set() + for result in recognize_all(sentence, self._trigger_intents): + trigger_id = int(result.intent.name) + if trigger_id in matched_triggers: + # Already matched a sentence from this trigger + break + + matched_triggers.add(trigger_id) + + if not matched_triggers: + # Sentence did not match any trigger sentences + return None + + _LOGGER.debug( + "'%s' matched %s trigger(s): %s", + sentence, + len(matched_triggers), + matched_triggers, + ) + + # Gather callback responses in parallel + trigger_responses = await asyncio.gather( + *( + self._trigger_sentences[trigger_id].callback(sentence) + for trigger_id in matched_triggers + ) + ) + + # Use last non-empty result as speech response + speech: str | None = None + for trigger_response in trigger_responses: + speech = speech or trigger_response + + response = intent.IntentResponse(language=self.hass.config.language) + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_speech(speech or "") + + return ConversationResult(response=response) + def _make_error_result( language: str, diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 01276d56081..aa2d0c32d16 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.0.6", "home-assistant-intents==2023.6.5"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.6.28"] } diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 6b031ff7142..1a28044dcb5 100644 --- a/homeassistant/components/conversation/services.yaml +++ b/homeassistant/components/conversation/services.yaml @@ -7,6 +7,7 @@ process: name: Text description: Transcribed text example: Turn all lights on + required: true selector: text: language: @@ -20,4 +21,4 @@ process: description: Assist engine to process your request example: homeassistant selector: - text: + conversation_agent: diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py new file mode 100644 index 00000000000..b64b74c5fa6 --- /dev/null +++ b/homeassistant/components/conversation/trigger.py @@ -0,0 +1,72 @@ +"""Offer sentence based automation rules.""" +from __future__ import annotations + +from typing import Any + +from hassil.recognize import PUNCTUATION +import voluptuous as vol + +from homeassistant.const import CONF_COMMAND, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import HOME_ASSISTANT_AGENT, _get_agent_manager +from .const import DOMAIN +from .default_agent import DefaultAgent + + +def has_no_punctuation(value: list[str]) -> list[str]: + """Validate result does not contain punctuation.""" + for sentence in value: + if PUNCTUATION.search(sentence): + raise vol.Invalid("sentence should not contain punctuation") + + return value + + +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): DOMAIN, + vol.Required(CONF_COMMAND): vol.All( + cv.ensure_list, [cv.string], has_no_punctuation + ), + } +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for events based on configuration.""" + trigger_data = trigger_info["trigger_data"] + sentences = config.get(CONF_COMMAND, []) + + job = HassJob(action) + + @callback + async def call_action(sentence: str) -> str | None: + """Call action with right context.""" + trigger_input: dict[str, Any] = { # Satisfy type checker + **trigger_data, + "platform": DOMAIN, + "sentence": sentence, + } + + # Wait for the automation to complete + if future := hass.async_run_hass_job( + job, + {"trigger": trigger_input}, + ): + await future + + return "Done" + + default_agent = await _get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) + assert isinstance(default_agent, DefaultAgent) + + return default_agent.register_trigger(sentences, call_action) diff --git a/homeassistant/components/coolmaster/binary_sensor.py b/homeassistant/components/coolmaster/binary_sensor.py index 884cf98b742..29fd5797124 100644 --- a/homeassistant/components/coolmaster/binary_sensor.py +++ b/homeassistant/components/coolmaster/binary_sensor.py @@ -32,12 +32,11 @@ async def async_setup_entry( class CoolmasterCleanFilter(CoolmasterEntity, BinarySensorEntity): """Representation of a unit's filter state (true means need to be cleaned).""" - _attr_has_entity_name = True entity_description = BinarySensorEntityDescription( key="clean_filter", + translation_key="clean_filter", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Clean filter", icon="mdi:air-filter", ) diff --git a/homeassistant/components/coolmaster/button.py b/homeassistant/components/coolmaster/button.py index a32a9833dd9..e4dfb371a0b 100644 --- a/homeassistant/components/coolmaster/button.py +++ b/homeassistant/components/coolmaster/button.py @@ -28,11 +28,10 @@ async def async_setup_entry( class CoolmasterResetFilter(CoolmasterEntity, ButtonEntity): """Reset the clean filter timer (once filter was cleaned).""" - _attr_has_entity_name = True entity_description = ButtonEntityDescription( key="reset_filter", + translation_key="reset_filter", entity_category=EntityCategory.CONFIG, - name="Reset filter", icon="mdi:air-filter", ) diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index d27f776c655..6ae6613bcca 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -53,6 +53,8 @@ async def async_setup_entry( class CoolmasterClimate(CoolmasterEntity, ClimateEntity): """Representation of a coolmaster climate device.""" + _attr_name = None + def __init__(self, coordinator, unit_id, info, supported_modes): """Initialize the climate device.""" super().__init__(coordinator, unit_id, info) @@ -63,11 +65,6 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): """Return unique ID for this device.""" return self._unit_id - @property - def name(self): - """Return the name of the climate device.""" - return self.unique_id - @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" diff --git a/homeassistant/components/coolmaster/entity.py b/homeassistant/components/coolmaster/entity.py index 65f21b77534..1607e220a55 100644 --- a/homeassistant/components/coolmaster/entity.py +++ b/homeassistant/components/coolmaster/entity.py @@ -12,6 +12,8 @@ from .const import DOMAIN class CoolmasterEntity(CoordinatorEntity[CoolmasterDataUpdateCoordinator]): """Representation of a Coolmaster entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: CoolmasterDataUpdateCoordinator, diff --git a/homeassistant/components/coolmaster/sensor.py b/homeassistant/components/coolmaster/sensor.py index 59b0e71abb2..5c6774e8c92 100644 --- a/homeassistant/components/coolmaster/sensor.py +++ b/homeassistant/components/coolmaster/sensor.py @@ -28,11 +28,10 @@ async def async_setup_entry( class CoolmasterCleanFilter(CoolmasterEntity, SensorEntity): """Representation of a unit's error code.""" - _attr_has_entity_name = True entity_description = SensorEntityDescription( key="error_code", + translation_key="error_code", entity_category=EntityCategory.DIAGNOSTIC, - name="Error code", icon="mdi:alert", ) diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 6bba26b6bc9..7baa6444c1d 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -19,5 +19,22 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_units": "Could not find any HVAC units in CoolMasterNet host." } + }, + "entity": { + "binary_sensor": { + "clean_filter": { + "name": "Clean filter" + } + }, + "button": { + "reset_filter": { + "name": "Reset filter" + } + }, + "sensor": { + "error_code": { + "name": "Error code" + } + } } } diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index d2834b8991b..6f3d48fc1bb 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -292,7 +292,7 @@ class Counter(collection.CollectionEntity, RestoreEntity): self.hass, DOMAIN, "deprecated_configure_service", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=True, is_persistent=True, severity=IssueSeverity.WARNING, diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index dd22821d5e4..e34a623be93 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -42,21 +43,28 @@ POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} CMD_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(CMD_ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ) POSITION_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(POSITION_ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Optional("position", default=0): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) ), } ) -ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) +_ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) + + +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( @@ -77,7 +85,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } if supported_features & SUPPORT_SET_POSITION: diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index 6144bdb6dbf..2aa0a1dd2fb 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -4,7 +4,6 @@ from __future__ import annotations import voluptuous as vol from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_ABOVE, CONF_BELOW, CONF_CONDITION, @@ -43,7 +42,7 @@ STATE_CONDITION_TYPES = {"is_open", "is_closed", "is_opening", "is_closing"} POSITION_CONDITION_SCHEMA = vol.All( DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(POSITION_CONDITION_TYPES), vol.Optional(CONF_ABOVE): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) @@ -58,7 +57,7 @@ POSITION_CONDITION_SCHEMA = vol.All( STATE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(STATE_CONDITION_TYPES), } ) @@ -86,7 +85,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } if supports_open_close: @@ -127,6 +126,9 @@ def async_condition_from_config( hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[CONF_ENTITY_ID]) + if config[CONF_TYPE] in STATE_CONDITION_TYPES: if config[CONF_TYPE] == "is_open": state = STATE_OPEN @@ -139,7 +141,7 @@ def async_condition_from_config( def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state @@ -156,7 +158,7 @@ def async_condition_from_config( ) -> bool: """Return whether the criteria are met.""" return condition.async_numeric_state( - hass, config[ATTR_ENTITY_ID], max_pos, min_pos, attribute=position_attr + hass, entity_id, max_pos, min_pos, attribute=position_attr ) return check_numeric_state diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index aad225c8039..2fb456d726d 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -43,7 +43,7 @@ STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} POSITION_TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(POSITION_TRIGGER_TYPES), vol.Optional(CONF_ABOVE): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) @@ -58,7 +58,7 @@ POSITION_TRIGGER_SCHEMA = vol.All( STATE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(STATE_TRIGGER_TYPES), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -87,7 +87,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } if supports_open_close: diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index c71de53ebbe..7eb3cfab753 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -35,6 +35,7 @@ class CPUSpeedSensor(SensorEntity): _attr_device_class = SensorDeviceClass.FREQUENCY _attr_icon = "mdi:pulse" _attr_has_entity_name = True + _attr_name = None _attr_native_unit_of_measurement = UnitOfFrequency.GIGAHERTZ def __init__(self, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index c642eb9112e..dd5366dee6a 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -115,10 +115,15 @@ class CupsSensor(SensorEntity): def __init__(self, data: CupsData, printer_name: str) -> None: """Initialize the CUPS sensor.""" self.data = data - self._attr_name = printer_name + self._name = printer_name self._printer: dict[str, Any] | None = None self._attr_available = False + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + @property def native_value(self): """Return the state of the sensor.""" @@ -149,7 +154,6 @@ class CupsSensor(SensorEntity): def update(self) -> None: """Get the latest data and updates the states.""" self.data.update() - assert self.name is not None assert self.data.printers is not None self._printer = self.data.printers.get(self.name) self._attr_available = self.data.available diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index bd0e846ea4d..5ede11c60b6 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -276,17 +276,16 @@ class DaikinClimate(ClimateEntity): await self._api.device.set_advanced_mode( HA_PRESET_TO_DAIKIN[PRESET_ECO], ATTR_STATE_ON ) - else: - if self.preset_mode == PRESET_AWAY: - await self._api.device.set_holiday(ATTR_STATE_OFF) - elif self.preset_mode == PRESET_BOOST: - await self._api.device.set_advanced_mode( - HA_PRESET_TO_DAIKIN[PRESET_BOOST], ATTR_STATE_OFF - ) - elif self.preset_mode == PRESET_ECO: - await self._api.device.set_advanced_mode( - HA_PRESET_TO_DAIKIN[PRESET_ECO], ATTR_STATE_OFF - ) + elif self.preset_mode == PRESET_AWAY: + await self._api.device.set_holiday(ATTR_STATE_OFF) + elif self.preset_mode == PRESET_BOOST: + await self._api.device.set_advanced_mode( + HA_PRESET_TO_DAIKIN[PRESET_BOOST], ATTR_STATE_OFF + ) + elif self.preset_mode == PRESET_ECO: + await self._api.device.set_advanced_mode( + HA_PRESET_TO_DAIKIN[PRESET_ECO], ATTR_STATE_OFF + ) @property def preset_modes(self): diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 8139d77df85..6245558a1c5 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==112"], + "requirements": ["pydeconz==113"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index e5d5de41008..4e00ac0a415 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -16,6 +16,7 @@ from pydeconz.models.sensor.daylight import DAYLIGHT_STATUS, Daylight from pydeconz.models.sensor.generic_status import GenericStatus from pydeconz.models.sensor.humidity import Humidity from pydeconz.models.sensor.light_level import LightLevel +from pydeconz.models.sensor.moisture import Moisture from pydeconz.models.sensor.power import Power from pydeconz.models.sensor.pressure import Pressure from pydeconz.models.sensor.switch import Switch @@ -81,6 +82,7 @@ T = TypeVar( GenericStatus, Humidity, LightLevel, + Moisture, Power, Pressure, Temperature, @@ -206,6 +208,17 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, ), + DeconzSensorDescription[Moisture]( + key="moisture", + supported_fn=lambda device: device.moisture is not None, + update_key="moisture", + value_fn=lambda device: device.scaled_moisture, + instance_check=Moisture, + device_class=SensorDeviceClass.MOISTURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, + ), DeconzSensorDescription[Power]( key="power", supported_fn=lambda device: device.power is not None, diff --git a/homeassistant/components/decora_wifi/manifest.json b/homeassistant/components/decora_wifi/manifest.json index 592942ee99b..0bead527e78 100644 --- a/homeassistant/components/decora_wifi/manifest.json +++ b/homeassistant/components/decora_wifi/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/decora_wifi", "iot_class": "cloud_polling", "loggers": ["decora_wifi"], - "requirements": ["decora_wifi==1.4"] + "requirements": ["decora-wifi==1.4"] } diff --git a/homeassistant/components/delijn/manifest.json b/homeassistant/components/delijn/manifest.json index 81307c47bba..d25dab4234e 100644 --- a/homeassistant/components/delijn/manifest.json +++ b/homeassistant/components/delijn/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/delijn", "iot_class": "cloud_polling", "loggers": ["pydelijn"], - "requirements": ["pydelijn==1.0.0"] + "requirements": ["pydelijn==1.1.0"] } diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index eed194640dd..9242e3e2d5e 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -28,11 +28,11 @@ def get_state(data: dict[str, float], key: str) -> str | float: download = data[DATA_KEYS[1]] - data[DATA_KEYS[3]] if key == CURRENT_STATUS: if upload > 0 and download > 0: - return "Up/Down" + return "seeding_and_downloading" if upload > 0 and download == 0: - return "Seeding" + return "seeding" if upload == 0 and download > 0: - return "Downloading" + return "downloading" return STATE_IDLE kb_spd = float(upload if key == UPLOAD_SPEED else download) / 1024 return round(kb_spd, 2 if kb_spd < 0.1 else 1) @@ -48,12 +48,14 @@ class DelugeSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = ( DelugeSensorEntityDescription( key=CURRENT_STATUS, - name="Status", + translation_key="status", value=lambda data: get_state(data, CURRENT_STATUS), + device_class=SensorDeviceClass.ENUM, + options=["seeding_and_downloading", "seeding", "downloading", "idle"], ), DelugeSensorEntityDescription( key=DOWNLOAD_SPEED, - name="Down speed", + translation_key="download_speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -61,7 +63,7 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = ( ), DelugeSensorEntityDescription( key=UPLOAD_SPEED, - name="Up speed", + translation_key="upload_speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index f11e1a2bd3e..e0266d004e2 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -19,5 +19,24 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status", + "state": { + "seeding_and_downloading": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading", + "idle": "[%key:common::state::idle%]" + } + }, + "download_speed": { + "name": "Download speed" + }, + "upload_speed": { + "name": "Upload speed" + } + } } } diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index f9e89543d26..483b02844d6 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -24,6 +24,8 @@ async def async_setup_entry( class DelugeSwitch(DelugeEntity, SwitchEntity): """Representation of a Deluge switch.""" + _attr_name = None + def __init__(self, coordinator: DelugeDataUpdateCoordinator) -> None: """Initialize the Deluge switch.""" super().__init__(coordinator) diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index a84d7bf4f0b..246c952e219 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -50,7 +50,6 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.TTS, - Platform.STT, Platform.MAILBOX, Platform.NOTIFY, Platform.IMAGE_PROCESSING, @@ -63,9 +62,6 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the demo environment.""" - if DOMAIN not in config: - return True - if not hass.config_entries.async_entries(DOMAIN): hass.async_create_task( hass.config_entries.flow.async_init( @@ -73,6 +69,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) + if DOMAIN not in config: + return True + # Set up demo platforms for platform in COMPONENTS_WITH_DEMO_PLATFORM: hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index ee718e85cc0..236d4bbb1b0 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -9,18 +9,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -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 binary sensor platform.""" + """Set up the demo binary sensor platform.""" async_add_entities( [ DemoBinarySensor( @@ -36,42 +34,30 @@ 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 DemoBinarySensor(BinarySensorEntity): """representation of a Demo binary sensor.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: bool, device_class: BinarySensorDeviceClass, ) -> None: """Initialize the demo sensor.""" self._unique_id = unique_id - self._attr_name = name self._state = state self._attr_device_class = device_class - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - name=self.name, + name=device_name, ) @property diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py index 626403009ce..f7a653e1779 100644 --- a/homeassistant/components/demo/button.py +++ b/homeassistant/components/demo/button.py @@ -4,59 +4,48 @@ from __future__ import annotations from homeassistant.components import persistent_notification from homeassistant.components.button import ButtonEntity 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 from . import DOMAIN -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the demo Button entity.""" - async_add_entities( - [ - DemoButton( - unique_id="push", - name="Push", - icon="mdi:gesture-tap-button", - ), - ] - ) - - 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) + """Set up the demo button platform.""" + async_add_entities( + [ + DemoButton( + unique_id="push", + device_name="Push", + icon="mdi:gesture-tap-button", + ), + ] + ) class DemoButton(ButtonEntity): """Representation of a demo button entity.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, icon: str, ) -> None: """Initialize the Demo button entity.""" self._attr_unique_id = unique_id - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_icon = icon self._attr_device_info = { "identifiers": {(DOMAIN, unique_id)}, - "name": name, + "name": device_name, } async def async_press(self) -> None: diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index ae546361d8f..73b45a55640 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -27,10 +27,10 @@ def setup_platform( def calendar_data_future() -> CalendarEvent: """Representation of a Demo Calendar for a future event.""" - one_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30) + half_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30) return CalendarEvent( - start=one_hour_from_now, - end=one_hour_from_now + datetime.timedelta(minutes=60), + start=half_hour_from_now, + end=half_hour_from_now + datetime.timedelta(minutes=60), summary="Future Event", description="Future Description", location="Future Location", @@ -67,4 +67,9 @@ class DemoCalendar(CalendarEntity): end_date: datetime.datetime, ) -> list[CalendarEvent]: """Return calendar events within a datetime range.""" + assert start_date < end_date + if self._event.start_datetime_local >= end_date: + return [] + if self._event.end_datetime_local < start_date: + return [] return [self._event] diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index b55fb4ba0e9..722693280a0 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -7,22 +7,6 @@ from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo camera platform.""" - async_add_entities( - [ - DemoCamera("Demo camera", "image/jpg"), - DemoCamera("Demo camera png", "image/png"), - ] - ) async def async_setup_entry( @@ -31,7 +15,12 @@ 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( + [ + DemoCamera("Demo camera", "image/jpg"), + DemoCamera("Demo camera png", "image/png"), + ] + ) class DemoCamera(Camera): diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 7c0a4a5c9c8..340a4b306cb 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -14,27 +14,24 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN SUPPORT_FLAGS = ClimateEntityFeature(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 climate devices.""" + """Set up the demo climate platform.""" async_add_entities( [ DemoClimate( unique_id="climate_1", - name="HeatPump", + device_name="HeatPump", target_temperature=68, unit_of_measurement=UnitOfTemperature.FAHRENHEIT, preset=None, @@ -52,7 +49,7 @@ async def async_setup_platform( ), DemoClimate( unique_id="climate_2", - name="Hvac", + device_name="Hvac", target_temperature=21, unit_of_measurement=UnitOfTemperature.CELSIUS, preset=None, @@ -70,7 +67,7 @@ async def async_setup_platform( ), DemoClimate( unique_id="climate_3", - name="Ecobee", + device_name="Ecobee", target_temperature=None, unit_of_measurement=UnitOfTemperature.CELSIUS, preset="home", @@ -91,25 +88,18 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo climate devices config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoClimate(ClimateEntity): """Representation of a demo climate device.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False _attr_translation_key = "ubercool" def __init__( self, unique_id: str, - name: str, + device_name: str, target_temperature: float | None, unit_of_measurement: str, preset: str | None, @@ -128,7 +118,6 @@ class DemoClimate(ClimateEntity): ) -> None: """Initialize the climate device.""" self._unique_id = unique_id - self._attr_name = name self._attr_supported_features = SUPPORT_FLAGS if target_temperature is not None: self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE @@ -163,17 +152,10 @@ class DemoClimate(ClimateEntity): self._swing_modes = ["auto", "1", "2", "3", "off"] self._target_temperature_high = target_temp_high self._target_temperature_low = target_temp_low - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={ - # Serial numbers are unique identifiers within a specific domain - (DOMAIN, self.unique_id) - }, - name=self.name, - ) + self._attr_device_info = { + "identifiers": {(DOMAIN, unique_id)}, + "name": device_name, + } @property def unique_id(self) -> str: diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 6f443329661..42e30aa8336 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -16,18 +16,16 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_utc_time_change -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -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 covers.""" + """Set up the demo cover platform.""" async_add_entities( [ DemoCover(hass, "cover_1", "Kitchen Window"), @@ -56,25 +54,18 @@ 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 DemoCover(CoverEntity): """Representation of a demo cover.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, hass: HomeAssistant, unique_id: str, - name: str, + device_name: str, position: int | None = None, tilt_position: int | None = None, device_class: CoverDeviceClass | None = None, @@ -83,7 +74,6 @@ class DemoCover(CoverEntity): """Initialize the cover.""" self.hass = hass self._unique_id = unique_id - self._attr_name = name self._position = position self._attr_device_class = device_class self._attr_supported_features = supported_features @@ -101,15 +91,12 @@ class DemoCover(CoverEntity): else: self._closed = position <= 0 - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - name=self.name, + name=device_name, ) @property diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py index eb96bc49038..4129d0d392a 100644 --- a/homeassistant/components/demo/date.py +++ b/homeassistant/components/demo/date.py @@ -5,22 +5,19 @@ from datetime import date from homeassistant.components.date import DateEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +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 date entity.""" + """Set up the demo date platform.""" async_add_entities( [ DemoDate( @@ -34,24 +31,17 @@ 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 DemoDate(DateEntity): """Representation of a Demo date entity.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: date, icon: str, assumed_state: bool, @@ -59,12 +49,11 @@ class DemoDate(DateEntity): """Initialize the Demo date entity.""" self._attr_assumed_state = assumed_state self._attr_icon = icon - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_native_value = state self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, name=self.name + identifiers={(DOMAIN, unique_id)}, name=device_name ) async def async_set_value(self, value: date) -> None: diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py index 88027f58b92..b769f9baba3 100644 --- a/homeassistant/components/demo/datetime.py +++ b/homeassistant/components/demo/datetime.py @@ -5,22 +5,19 @@ from datetime import datetime, timezone from homeassistant.components.datetime import DateTimeEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +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 date/time entity.""" + """Set up the demo datetime platform.""" async_add_entities( [ DemoDateTime( @@ -34,24 +31,17 @@ 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 DemoDateTime(DateTimeEntity): """Representation of a Demo date/time entity.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: datetime, icon: str, assumed_state: bool, @@ -59,7 +49,6 @@ class DemoDateTime(DateTimeEntity): """Initialize the Demo date/time entity.""" self._attr_assumed_state = assumed_state self._attr_icon = icon - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_native_value = state self._attr_unique_id = unique_id @@ -68,7 +57,7 @@ class DemoDateTime(DateTimeEntity): # Serial numbers are unique identifiers within a specific domain (DOMAIN, unique_id) }, - name=self.name, + name=device_name, ) async def async_set_value(self, value: datetime) -> None: diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py index 772726ac1d5..2e16a04e171 100644 --- a/homeassistant/components/demo/humidifier.py +++ b/homeassistant/components/demo/humidifier.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.components.humidifier import ( + HumidifierAction, HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, @@ -29,12 +30,16 @@ async def async_setup_platform( name="Humidifier", mode=None, target_humidity=68, + current_humidity=45, + action=HumidifierAction.HUMIDIFYING, device_class=HumidifierDeviceClass.HUMIDIFIER, ), DemoHumidifier( name="Dehumidifier", mode=None, target_humidity=54, + current_humidity=59, + action=HumidifierAction.DRYING, device_class=HumidifierDeviceClass.DEHUMIDIFIER, ), DemoHumidifier( @@ -66,17 +71,21 @@ class DemoHumidifier(HumidifierEntity): name: str, mode: str | None, target_humidity: int, + current_humidity: int | None = None, available_modes: list[str] | None = None, is_on: bool = True, + action: HumidifierAction | None = None, device_class: HumidifierDeviceClass | None = None, ) -> None: """Initialize the humidifier device.""" self._attr_name = name self._attr_is_on = is_on + self._attr_action = action self._attr_supported_features = SUPPORT_FLAGS if mode is not None: self._attr_supported_features |= HumidifierEntityFeature.MODES self._attr_target_humidity = target_humidity + self._attr_current_humidity = current_humidity self._attr_mode = mode self._attr_available_modes = available_modes self._attr_device_class = device_class diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index 2e5291b8a13..fbc35965dc4 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -20,7 +20,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN @@ -34,11 +33,10 @@ SUPPORT_DEMO = {ColorMode.HS, ColorMode.COLOR_TEMP} SUPPORT_DEMO_HS_WHITE = {ColorMode.HS, ColorMode.WHITE} -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 light platform.""" async_add_entities( @@ -47,28 +45,28 @@ async def async_setup_platform( available=True, effect_list=LIGHT_EFFECT_LIST, effect=LIGHT_EFFECT_LIST[0], - name="Bed Light", + device_name="Bed Light", state=False, unique_id="light_1", ), DemoLight( available=True, ct=LIGHT_TEMPS[1], - name="Ceiling Lights", + device_name="Ceiling Lights", state=True, unique_id="light_2", ), DemoLight( available=True, hs_color=LIGHT_COLORS[1], - name="Kitchen Lights", + device_name="Kitchen Lights", state=True, unique_id="light_3", ), DemoLight( available=True, ct=LIGHT_TEMPS[1], - name="Office RGBW Lights", + device_name="Office RGBW Lights", rgbw_color=(255, 0, 0, 255), state=True, supported_color_modes={ColorMode.RGBW}, @@ -76,7 +74,7 @@ async def async_setup_platform( ), DemoLight( available=True, - name="Living Room RGBWW Lights", + device_name="Living Room RGBWW Lights", rgbww_color=(255, 0, 0, 255, 0), state=True, supported_color_modes={ColorMode.RGBWW}, @@ -84,7 +82,7 @@ async def async_setup_platform( ), DemoLight( available=True, - name="Entrance Color + White Lights", + device_name="Entrance Color + White Lights", hs_color=LIGHT_COLORS[1], state=True, supported_color_modes=SUPPORT_DEMO_HS_WHITE, @@ -94,24 +92,17 @@ 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 DemoLight(LightEntity): """Representation of a demo light.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: bool, available: bool = False, brightness: int = 180, @@ -130,7 +121,6 @@ class DemoLight(LightEntity): self._effect = effect self._effect_list = effect_list self._hs_color = hs_color - self._attr_name = name self._rgbw_color = rgbw_color self._rgbww_color = rgbww_color self._state = state @@ -148,16 +138,12 @@ class DemoLight(LightEntity): self._color_modes = supported_color_modes if self._effect_list is not None: self._attr_supported_features |= LightEntityFeature.EFFECT - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, self.unique_id) }, - name=self.name, + name=device_name, ) @property diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py index 9627383443e..8aa3e1ef384 100644 --- a/homeassistant/components/demo/mailbox.py +++ b/homeassistant/components/demo/mailbox.py @@ -78,7 +78,7 @@ class DemoMailbox(Mailbox): """Return a list of the current messages.""" return sorted( self._messages.values(), - key=lambda item: item["info"]["origtime"], # type: ignore[no-any-return] + key=lambda item: item["info"]["origtime"], reverse=True, ) diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 25ed7347bda..719b1078b8c 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -3,22 +3,20 @@ from __future__ import annotations from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME, UnitOfTemperature +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -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 Number entity.""" + """Set up the demo number platform.""" async_add_entities( [ DemoNumber( @@ -77,24 +75,17 @@ 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 DemoNumber(NumberEntity): """Representation of a demo Number entity.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: float, icon: str, assumed_state: bool, @@ -111,7 +102,6 @@ class DemoNumber(NumberEntity): self._attr_device_class = device_class self._attr_icon = icon self._attr_mode = mode - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_unique_id = unique_id @@ -128,7 +118,7 @@ class DemoNumber(NumberEntity): # Serial numbers are unique identifiers within a specific domain (DOMAIN, unique_id) }, - name=self.name, + name=device_name, ) async def async_set_native_value(self, value: float) -> None: diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index e30d65c9f0e..6349b10040c 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -3,27 +3,24 @@ from __future__ import annotations from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +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 Select entity.""" + """Set up the demo select platform.""" async_add_entities( [ DemoSelect( unique_id="speed", - name="Speed", + device_name="Speed", icon="mdi:speedometer", current_option="ridiculous_speed", options=[ @@ -37,24 +34,17 @@ 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 DemoSelect(SelectEntity): """Representation of a demo select entity.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, icon: str, current_option: str | None, options: list[str], @@ -62,14 +52,13 @@ class DemoSelect(SelectEntity): ) -> None: """Initialize the Demo select entity.""" self._attr_unique_id = unique_id - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_current_option = current_option self._attr_icon = icon self._attr_options = options self._attr_translation_key = translation_key self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 84758f0c294..26689582fae 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -25,18 +25,17 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType from . import DOMAIN -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 sensors.""" + """Set up the demo sensor platform.""" async_add_entities( [ DemoSensor( @@ -126,7 +125,7 @@ async def async_setup_platform( ), DemoSensor( unique_id="sensor_10", - name=None, + device_name="Thermostat", state="eco", device_class=SensorDeviceClass.ENUM, state_class=None, @@ -139,24 +138,17 @@ 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 DemoSensor(SensorEntity): """Representation of a Demo sensor.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str | None, + device_name: str | None, state: StateType, device_class: SensorDeviceClass, state_class: SensorStateClass | None, @@ -167,10 +159,6 @@ class DemoSensor(SensorEntity): ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class - if name is not None: - self._attr_name = name - else: - self._attr_has_entity_name = True self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_state_class = state_class @@ -180,7 +168,7 @@ class DemoSensor(SensorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) if battery: @@ -196,7 +184,7 @@ class DemoSumSensor(RestoreSensor): def __init__( self, unique_id: str, - name: str, + device_name: str, five_minute_increase: float, device_class: SensorDeviceClass, state_class: SensorStateClass | None, @@ -207,7 +195,6 @@ class DemoSumSensor(RestoreSensor): """Initialize the sensor.""" self.entity_id = f"{SENSOR_DOMAIN}.{suggested_entity_id}" self._attr_device_class = device_class - self._attr_name = name self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = 0 self._attr_state_class = state_class @@ -216,7 +203,7 @@ class DemoSumSensor(RestoreSensor): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) if battery: diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 6458bf47397..8cbc287b71d 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -9,7 +9,6 @@ from homeassistant.components.stt import ( AudioCodecs, AudioFormats, AudioSampleRates, - Provider, SpeechMetadata, SpeechResult, SpeechResultState, @@ -18,20 +17,10 @@ from homeassistant.components.stt 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_LANGUAGES = ["en", "de"] -async def async_get_engine( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> Provider: - """Set up Demo speech component.""" - return DemoProvider() - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -86,48 +75,3 @@ class DemoProviderEntity(SpeechToTextEntity): pass return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) - - -class DemoProvider(Provider): - """Demo speech API provider.""" - - @property - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - return SUPPORT_LANGUAGES - - @property - def supported_formats(self) -> list[AudioFormats]: - """Return a list of supported formats.""" - return [AudioFormats.WAV] - - @property - def supported_codecs(self) -> list[AudioCodecs]: - """Return a list of supported codecs.""" - return [AudioCodecs.PCM] - - @property - def supported_bit_rates(self) -> list[AudioBitRates]: - """Return a list of supported bit rates.""" - return [AudioBitRates.BITRATE_16] - - @property - def supported_sample_rates(self) -> list[AudioSampleRates]: - """Return a list of supported sample rates.""" - return [AudioSampleRates.SAMPLERATE_16000, AudioSampleRates.SAMPLERATE_44100] - - @property - def supported_channels(self) -> list[AudioChannels]: - """Return a list of supported channels.""" - return [AudioChannels.CHANNEL_STEREO] - - async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] - ) -> SpeechResult: - """Process an audio stream to STT service.""" - - # Read available data - async for _ in stream: - pass - - return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index 2ad400ff3f7..49e06839be5 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -5,22 +5,19 @@ from typing import Any from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +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 switches.""" + """Set up the demo switch platform.""" async_add_entities( [ DemoSwitch("switch1", "Decorative Lights", True, None, True), @@ -36,24 +33,17 @@ 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 DemoSwitch(SwitchEntity): """Representation of a demo switch.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: bool, icon: str | None, assumed: bool, @@ -64,11 +54,10 @@ class DemoSwitch(SwitchEntity): self._attr_device_class = device_class self._attr_icon = icon self._attr_is_on = state - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=self.name, + name=device_name, ) def turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py index efce1af5c37..7c243b73ea5 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -3,40 +3,37 @@ from __future__ import annotations from homeassistant.components.text import TextEntity, TextMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( +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 Text entity.""" + """Set up the Demo text platform.""" async_add_entities( [ DemoText( unique_id="text", - name="Text", + device_name="Text", icon=None, native_value="Hello world", ), DemoText( unique_id="password", - name="Password", + device_name="Password", icon="mdi:text", native_value="Hello world", mode=TextMode.PASSWORD, ), DemoText( unique_id="text_1_to_5_char", - name="Text with 1 to 5 characters", + device_name="Text with 1 to 5 characters", icon="mdi:text", native_value="Hello", native_min=1, @@ -44,7 +41,7 @@ async def async_setup_platform( ), DemoText( unique_id="text_lowercase", - name="Text with only lower case characters", + device_name="Text with only lower case characters", icon="mdi:text", native_value="world", pattern=r"[a-z]+", @@ -53,24 +50,17 @@ 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 DemoText(TextEntity): """Representation of a demo text entity.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, icon: str | None, native_value: str | None, mode: TextMode = TextMode.TEXT, @@ -80,7 +70,6 @@ class DemoText(TextEntity): ) -> None: """Initialize the Demo text entity.""" self._attr_unique_id = unique_id - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_native_value = native_value self._attr_icon = icon self._attr_mode = mode @@ -92,7 +81,7 @@ class DemoText(TextEntity): self._attr_pattern = pattern self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) async def async_set_value(self, value: str) -> None: diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py index aafd425a024..0384c0822f4 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -5,43 +5,33 @@ from datetime import time from homeassistant.components.time import TimeEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo time entity.""" - async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) + """Set up the demo time platform.""" + async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) class DemoTime(TimeEntity): """Representation of a Demo time entity.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, unique_id: str, - name: str, + device_name: str, state: time, icon: str, assumed_state: bool, @@ -49,12 +39,11 @@ class DemoTime(TimeEntity): """Initialize the Demo time entity.""" self._attr_assumed_state = assumed_state self._attr_icon = icon - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_native_value = state self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, name=self.name + identifiers={(DOMAIN, unique_id)}, name=device_name ) async def async_set_value(self, value: time) -> None: diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py index 15e67ffa0a8..6373c485037 100644 --- a/homeassistant/components/demo/update.py +++ b/homeassistant/components/demo/update.py @@ -10,29 +10,26 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN FAKE_INSTALL_SLEEP_TIME = 0.5 -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 demo update entities.""" + """Set up demo update platform.""" async_add_entities( [ DemoUpdate( unique_id="update_no_install", - name="Demo Update No Install", + device_name="Demo Update No Install", title="Awesomesoft Inc.", installed_version="1.0.0", latest_version="1.0.1", @@ -42,14 +39,14 @@ async def async_setup_platform( ), DemoUpdate( unique_id="update_2_date", - name="Demo No Update", + device_name="Demo No Update", title="AdGuard Home", installed_version="1.0.0", latest_version="1.0.0", ), DemoUpdate( unique_id="update_addon", - name="Demo add-on", + device_name="Demo add-on", title="AdGuard Home", installed_version="1.0.0", latest_version="1.0.1", @@ -58,7 +55,7 @@ async def async_setup_platform( ), DemoUpdate( unique_id="update_light_bulb", - name="Demo Living Room Bulb Update", + device_name="Demo Living Room Bulb Update", title="Philips Lamps Firmware", installed_version="1.93.3", latest_version="1.94.2", @@ -68,7 +65,7 @@ async def async_setup_platform( ), DemoUpdate( unique_id="update_support_progress", - name="Demo Update with Progress", + device_name="Demo Update with Progress", title="Philips Lamps Firmware", installed_version="1.93.3", latest_version="1.94.2", @@ -82,15 +79,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) - - async def _fake_install() -> None: """Fake install an update.""" await asyncio.sleep(FAKE_INSTALL_SLEEP_TIME) @@ -99,13 +87,15 @@ async def _fake_install() -> None: class DemoUpdate(UpdateEntity): """Representation of a demo update entity.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False def __init__( self, *, unique_id: str, - name: str, + device_name: str, title: str | None, installed_version: str | None, latest_version: str | None, @@ -120,14 +110,13 @@ class DemoUpdate(UpdateEntity): self._attr_installed_version = installed_version self._attr_device_class = device_class self._attr_latest_version = latest_version - self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_release_summary = release_summary self._attr_release_url = release_url self._attr_title = title self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=name, + name=device_name, ) if support_install: self._attr_supported_features |= ( diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index e1cc278137c..af04da27406 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -19,7 +19,12 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -86,6 +91,28 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE] ) + source_entity = registry.async_get(source_entity_id) + dev_reg = dr.async_get(hass) + # Resolve source entity device + if ( + (source_entity is not None) + and (source_entity.device_id is not None) + and ( + ( + device := dev_reg.async_get( + device_id=source_entity.device_id, + ) + ) + is not None + ) + ): + device_info = DeviceInfo( + identifiers=device.identifiers, + connections=device.connections, + ) + else: + device_info = None + unit_prefix = config_entry.options[CONF_UNIT_PREFIX] if unit_prefix == "none": unit_prefix = None @@ -99,6 +126,7 @@ async def async_setup_entry( unit_of_measurement=None, unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], + device_info=device_info, ) async_add_entities([derivative_sensor]) @@ -142,9 +170,11 @@ class DerivativeSensor(RestoreSensor, SensorEntity): unit_prefix: str | None, unit_time: UnitOfTime, unique_id: str | None, + device_info: DeviceInfo | None = None, ) -> None: """Initialize the derivative sensor.""" self._attr_unique_id = unique_id + self._attr_device_info = device_info self._sensor_source_id = source_entity self._round_digits = round_digits self._state: float | int | Decimal = 0 diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 71acc6dfa79..af2fd61081c 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, CONF_DOMAIN, + CONF_ENTITY_ID, CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, callback @@ -40,7 +41,7 @@ from .const import ( # noqa: F401 CONF_TURNED_OFF, CONF_TURNED_ON, ) -from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig +from .exceptions import DeviceNotFound, EntityNotFound, InvalidDeviceAutomationConfig if TYPE_CHECKING: from .action import DeviceAutomationActionProtocol @@ -313,7 +314,7 @@ async def _async_get_device_automation_capabilities( try: capabilities = await getattr(platform, function_name)(hass, automation) - except InvalidDeviceAutomationConfig: + except (EntityNotFound, InvalidDeviceAutomationConfig): return {} capabilities = capabilities.copy() @@ -328,6 +329,33 @@ async def _async_get_device_automation_capabilities( return capabilities # type: ignore[no-any-return] +@callback +def async_get_entity_registry_entry_or_raise( + hass: HomeAssistant, entity_registry_id: str +) -> er.RegistryEntry: + """Get an entity registry entry from entry ID or raise.""" + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(entity_registry_id) + if entry is None: + raise EntityNotFound + return entry + + +@callback +def async_validate_entity_schema( + hass: HomeAssistant, config: ConfigType, schema: vol.Schema +) -> ConfigType: + """Validate schema and resolve entity registry entry id to entity_id.""" + config = schema(config) + + registry = er.async_get(hass) + config[CONF_ENTITY_ID] = er.async_resolve_entity_id( + registry, config[CONF_ENTITY_ID] + ) + + return config + + def handle_device_errors( func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None]] ) -> Callable[ diff --git a/homeassistant/components/device_automation/entity.py b/homeassistant/components/device_automation/entity.py index f38daf2dae6..87ff5a2cb52 100644 --- a/homeassistant/components/device_automation/entity.py +++ b/homeassistant/components/device_automation/entity.py @@ -23,7 +23,7 @@ ENTITY_TRIGGERS = [ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In([CONF_CHANGED_STATES]), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -73,7 +73,7 @@ async def _async_get_automations( { **template, "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": domain, } for template in automation_templates diff --git a/homeassistant/components/device_automation/exceptions.py b/homeassistant/components/device_automation/exceptions.py index ad92696cb94..0b2f2c01be7 100644 --- a/homeassistant/components/device_automation/exceptions.py +++ b/homeassistant/components/device_automation/exceptions.py @@ -8,3 +8,7 @@ class InvalidDeviceAutomationConfig(HomeAssistantError): class DeviceNotFound(HomeAssistantError): """When referenced device not found.""" + + +class EntityNotFound(HomeAssistantError): + """When referenced entity not found.""" diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 5f844c36aa5..038ded07e8a 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -5,7 +5,7 @@ from typing import cast import voluptuous as vol -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType @@ -25,6 +25,24 @@ STATIC_VALIDATOR = { DeviceAutomationType.TRIGGER: "TRIGGER_SCHEMA", } +ENTITY_PLATFORMS = { + Platform.ALARM_CONTROL_PANEL.value, + Platform.BUTTON.value, + Platform.CLIMATE.value, + Platform.COVER.value, + Platform.FAN.value, + Platform.HUMIDIFIER.value, + Platform.LIGHT.value, + Platform.LOCK.value, + Platform.NUMBER.value, + Platform.REMOTE.value, + Platform.SELECT.value, + Platform.SWITCH.value, + Platform.TEXT.value, + Platform.VACUUM.value, + Platform.WATER_HEATER.value, +} + async def async_validate_device_automation_config( hass: HomeAssistant, @@ -43,6 +61,16 @@ async def async_validate_device_automation_config( ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config) ) + # Bypass checks for entity platforms + if ( + automation_type == DeviceAutomationType.ACTION + and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS + ): + 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) diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index e5061cb691e..189fc750e50 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -78,14 +78,14 @@ DEVICE_ACTION_TYPES = [CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON] ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(DEVICE_ACTION_TYPES), } ) CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In([CONF_IS_OFF, CONF_IS_ON]), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -93,7 +93,7 @@ CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( _TOGGLE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In([CONF_TURNED_OFF, CONF_TURNED_ON]), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -196,7 +196,7 @@ async def _async_get_automations( { **template, "device_id": device_id, - "entity_id": entry.entity_id, + "entity_id": entry.id, "domain": domain, } for template in automation_templates diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index c4450ab60a7..286929c5345 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -373,7 +373,6 @@ class ScannerEntity(BaseTrackerEntity): # Entities without a unique ID don't have a device if ( not self.registry_entry - or not self.platform or not self.platform.config_entry or not self.mac_address or (device_entry := self.find_device_entry()) is None diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 96ee70baca8..b5bf850b4fa 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -27,7 +27,7 @@ CONDITION_TYPES = {"is_home", "is_not_home"} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -50,7 +50,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] @@ -63,12 +63,14 @@ def async_condition_from_config( hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) reverse = config[CONF_TYPE] == "is_not_home" @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - result = condition.state(hass, config[ATTR_ENTITY_ID], STATE_HOME) + result = condition.state(hass, entity_id, STATE_HOME) if reverse: result = not result return result diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index 150b5872275..a96f9affb1d 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -27,7 +27,7 @@ TRIGGER_TYPES: Final[set[str]] = {"enters", "leaves"} TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN_ZONE), } @@ -51,7 +51,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "enters", } ) @@ -60,7 +60,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "leaves", } ) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index e27ff57f03f..b428018cd9e 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -726,6 +726,10 @@ class DeviceTracker: class Device(RestoreEntity): """Base class for a tracked device.""" + # This entity is legacy and does not have a platform. + # We can't fix this easily without breaking changes. + _no_platform_reported = True + host_name: str | None = None location_name: str | None = None gps: GPSType | None = None diff --git a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py index eafd1e63b1f..d2608ed43c7 100644 --- a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py +++ b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py @@ -8,6 +8,8 @@ from .devolo_device import DevoloDeviceEntity class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" + _attr_name = None + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 4ccd5a00ab2..93a66e345ec 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -71,13 +71,12 @@ class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): self._multi_level_switch_property.set( round(kwargs[ATTR_BRIGHTNESS] / 255 * 100) ) + elif self._binary_switch_property is not None: + # Turn on the light device to the latest known value. The value is known by the device itself. + self._binary_switch_property.set(True) else: - if self._binary_switch_property is not None: - # Turn on the light device to the latest known value. The value is known by the device itself. - self._binary_switch_property.set(True) - else: - # If there is no binary switch attached to the device, turn it on to 100 %. - self._multi_level_switch_property.set(100) + # If there is no binary switch attached to the device, turn it on to 100 %. + self._multi_level_switch_property.set(100) def turn_off(self, **kwargs: Any) -> None: """Turn device off.""" diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index 24b1d3545de..9b96e58da60 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -41,6 +41,8 @@ async def async_setup_entry( class DevoloSwitch(DevoloDeviceEntity, SwitchEntity): """Representation of a switch.""" + _attr_name = None + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index bf53022b43b..b9958dc7309 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -20,53 +20,35 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] username = config_entry.data[CONF_USERNAME] unit_of_measurement = config_entry.options[CONF_UNIT_OF_MEASUREMENT] - sensors: list[SensorEntity] = [] - sensors.append(DexcomGlucoseTrendSensor(coordinator, username)) - sensors.append(DexcomGlucoseValueSensor(coordinator, username, unit_of_measurement)) - async_add_entities(sensors, False) + async_add_entities( + [ + DexcomGlucoseTrendSensor(coordinator, username), + DexcomGlucoseValueSensor(coordinator, username, unit_of_measurement), + ], + False, + ) class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity): """Representation of a Dexcom glucose value sensor.""" + _attr_icon = GLUCOSE_VALUE_ICON + def __init__(self, coordinator, username, unit_of_measurement): """Initialize the sensor.""" super().__init__(coordinator) - self._state = None - self._unit_of_measurement = unit_of_measurement - self._attribute_unit_of_measurement = ( - "mg_dl" if unit_of_measurement == MG_DL else "mmol_l" - ) - self._name = f"{DOMAIN}_{username}_glucose_value" - self._unique_id = f"{username}-value" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Return the icon for the frontend.""" - return GLUCOSE_VALUE_ICON - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of the device.""" - return self._unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement + self._key = "mg_dl" if unit_of_measurement == MG_DL else "mmol_l" + self._attr_name = f"{DOMAIN}_{username}_glucose_value" + self._attr_unique_id = f"{username}-value" @property def native_value(self): """Return the state of the sensor.""" if self.coordinator.data: - return getattr(self.coordinator.data, self._attribute_unit_of_measurement) + return getattr(self.coordinator.data, self._key) return None - @property - def unique_id(self): - """Device unique id.""" - return self._unique_id - class DexcomGlucoseTrendSensor(CoordinatorEntity, SensorEntity): """Representation of a Dexcom glucose trend sensor.""" @@ -74,14 +56,8 @@ class DexcomGlucoseTrendSensor(CoordinatorEntity, SensorEntity): def __init__(self, coordinator, username): """Initialize the sensor.""" super().__init__(coordinator) - self._state = None - self._name = f"{DOMAIN}_{username}_glucose_trend" - self._unique_id = f"{username}-trend" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._attr_name = f"{DOMAIN}_{username}_glucose_trend" + self._attr_unique_id = f"{username}-trend" @property def icon(self): @@ -96,8 +72,3 @@ class DexcomGlucoseTrendSensor(CoordinatorEntity, SensorEntity): if self.coordinator.data: return self.coordinator.data.trend_description return None - - @property - def unique_id(self): - """Device unique id.""" - return self._unique_id diff --git a/homeassistant/components/directv/entity.py b/homeassistant/components/directv/entity.py index 08b24a50a75..9d1fd68b742 100644 --- a/homeassistant/components/directv/entity.py +++ b/homeassistant/components/directv/entity.py @@ -1,6 +1,8 @@ """Base DirecTV Entity.""" from __future__ import annotations +from typing import cast + from directv import DIRECTV from homeassistant.helpers.entity import DeviceInfo, Entity @@ -24,7 +26,10 @@ class DIRECTVEntity(Entity): return DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer=self.dtv.device.info.brand, - name=self.name, + # Instead of setting the device name to the entity name, directv + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), sw_version=self.dtv.device.info.version, via_device=(DOMAIN, self.dtv.device.info.receiver_id), ) diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json index 2b405341841..fceb214aded 100644 --- a/homeassistant/components/discogs/manifest.json +++ b/homeassistant/components/discogs/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/discogs", "iot_class": "cloud_polling", "loggers": ["discogs_client"], - "requirements": ["discogs_client==2.3.0"] + "requirements": ["discogs-client==2.3.0"] } diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py index a52c079ac8e..329709e88d2 100644 --- a/homeassistant/components/discord/__init__.py +++ b/homeassistant/components/discord/__init__.py @@ -6,13 +6,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Discord component.""" diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py new file mode 100644 index 00000000000..54f6fca83d4 --- /dev/null +++ b/homeassistant/components/discovergy/__init__.py @@ -0,0 +1,93 @@ +"""The Discovergy integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +import pydiscovergy +from pydiscovergy.authentication import BasicAuth +import pydiscovergy.error as discovergyError +from pydiscovergy.models import Meter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client + +from .const import APP_NAME, DOMAIN +from .coordinator import DiscovergyUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +@dataclass +class DiscovergyData: + """Discovergy data class to share meters and api client.""" + + api_client: pydiscovergy.Discovergy + meters: list[Meter] + coordinators: dict[str, DiscovergyUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Discovergy from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + # init discovergy data class + discovergy_data = DiscovergyData( + api_client=pydiscovergy.Discovergy( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + app_name=APP_NAME, + httpx_client=get_async_client(hass), + authentication=BasicAuth(), + ), + meters=[], + coordinators={}, + ) + + try: + # try to get meters from api to check if credentials are still valid and for later use + # if no exception is raised everything is fine to go + discovergy_data.meters = await discovergy_data.api_client.get_meters() + except discovergyError.InvalidLogin as err: + raise ConfigEntryAuthFailed("Invalid email or password") from err + except Exception as err: # pylint: disable=broad-except + raise ConfigEntryNotReady( + "Unexpected error while while getting meters" + ) from err + + # Init coordinators for meters + for meter in discovergy_data.meters: + # Create coordinator for meter, set config entry and fetch initial data, + # so we have data when entities are added + coordinator = DiscovergyUpdateCoordinator( + hass=hass, + config_entry=entry, + meter=meter, + discovergy_client=discovergy_data.api_client, + ) + await coordinator.async_config_entry_first_refresh() + + discovergy_data.coordinators[meter.get_meter_id()] = coordinator + + hass.data[DOMAIN][entry.entry_id] = discovergy_data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py new file mode 100644 index 00000000000..d6b81ed8837 --- /dev/null +++ b/homeassistant/components/discovergy/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow for Discovergy integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import pydiscovergy +from pydiscovergy.authentication import BasicAuth +import pydiscovergy.error as discovergyError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.httpx_client import get_async_client + +from .const import APP_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def make_schema(email: str = "", password: str = "") -> vol.Schema: + """Create schema for config flow.""" + return vol.Schema( + { + vol.Required( + CONF_EMAIL, + default=email, + ): str, + vol.Required( + CONF_PASSWORD, + default=password, + ): str, + } + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Discovergy.""" + + VERSION = 1 + + existing_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=make_schema(), + ) + + return await self._validate_and_save(user_input) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle the initial step.""" + self.existing_entry = await self.async_set_unique_id(self.context["unique_id"]) + + 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( + self, user_input: Mapping[str, Any] | None = None, step_id: str = "user" + ) -> FlowResult: + """Validate user input and create config entry.""" + errors = {} + + if user_input: + try: + await pydiscovergy.Discovergy( + email=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + app_name=APP_NAME, + httpx_client=get_async_client(self.hass), + authentication=BasicAuth(), + ).get_meters() + except discovergyError.HTTPError: + errors["base"] = "cannot_connect" + except discovergyError.InvalidLogin: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error occurred while getting meters") + errors["base"] = "unknown" + else: + if self.existing_entry: + self.hass.config_entries.async_update_entry( + self.existing_entry, + data={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + await self.hass.config_entries.async_reload( + self.existing_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + + # set unique id to title which is the account email + await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + return self.async_show_form( + step_id=step_id, + data_schema=make_schema(), + errors=errors, + ) diff --git a/homeassistant/components/discovergy/const.py b/homeassistant/components/discovergy/const.py new file mode 100644 index 00000000000..866e9f11def --- /dev/null +++ b/homeassistant/components/discovergy/const.py @@ -0,0 +1,6 @@ +"""Constants for the Discovergy integration.""" +from __future__ import annotations + +DOMAIN = "discovergy" +MANUFACTURER = "Discovergy" +APP_NAME = "homeassistant" diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py new file mode 100644 index 00000000000..e3b6e91e03f --- /dev/null +++ b/homeassistant/components/discovergy/coordinator.py @@ -0,0 +1,60 @@ +"""DataUpdateCoordinator for the Discovergy integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from pydiscovergy import Discovergy +from pydiscovergy.error import AccessTokenExpired, HTTPError +from pydiscovergy.models import Meter, Reading + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): + """The Discovergy update coordinator.""" + + config_entry: ConfigEntry + discovergy_client: Discovergy + meter: Meter + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + meter: Meter, + discovergy_client: Discovergy, + ) -> None: + """Initialize the Discovergy coordinator.""" + self.config_entry = config_entry + self.meter = meter + self.discovergy_client = discovergy_client + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self) -> Reading: + """Get last reading for meter.""" + try: + return await self.discovergy_client.get_last_reading( + self.meter.get_meter_id() + ) + except AccessTokenExpired as err: + raise ConfigEntryAuthFailed( + f"Auth expired while fetching last reading for meter {self.meter.get_meter_id()}" + ) from err + except HTTPError as err: + raise UpdateFailed( + f"Error while fetching last reading for meter {self.meter.get_meter_id()}" + ) from err diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py new file mode 100644 index 00000000000..02d5585c1dc --- /dev/null +++ b/homeassistant/components/discovergy/diagnostics.py @@ -0,0 +1,49 @@ +"""Diagnostics support for discovergy.""" +from __future__ import annotations + +from typing import Any + +from pydiscovergy.models import Meter + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from . import DiscovergyData +from .const import DOMAIN + +TO_REDACT_CONFIG_ENTRY = {CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, "title"} + +TO_REDACT_METER = { + "serial_number", + "full_serial_number", + "location", + "fullSerialNumber", + "printedFullSerialNumber", + "administrationNumber", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + flattened_meter: list[dict] = [] + last_readings: dict[str, dict] = {} + data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] + meters: list[Meter] = data.meters # always returns a list + + for meter in meters: + # make a dict of meter data and redact some data + flattened_meter.append(async_redact_data(meter.__dict__, TO_REDACT_METER)) + + # get last reading for meter and make a dict of it + coordinator = data.coordinators[meter.get_meter_id()] + last_readings[meter.get_meter_id()] = coordinator.data.__dict__ + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT_CONFIG_ENTRY), + "meters": flattened_meter, + "readings": last_readings, + } diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json new file mode 100644 index 00000000000..c929386e8e8 --- /dev/null +++ b/homeassistant/components/discovergy/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "discovergy", + "name": "Discovergy", + "codeowners": ["@jpbede"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/discovergy", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["pydiscovergy==1.2.1"] +} diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py new file mode 100644 index 00000000000..35955a6b189 --- /dev/null +++ b/homeassistant/components/discovergy/sensor.py @@ -0,0 +1,213 @@ +"""Discovergy sensor entity.""" +from dataclasses import dataclass, field + +from pydiscovergy.models import Meter + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) +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 . import DiscovergyData, DiscovergyUpdateCoordinator +from .const import DOMAIN, MANUFACTURER + +PARALLEL_UPDATES = 1 + + +@dataclass +class DiscovergyMixin: + """Mixin for alternative keys.""" + + alternative_keys: list[str] = field(default_factory=lambda: []) + scale: int = field(default_factory=lambda: 1000) + + +@dataclass +class DiscovergySensorEntityDescription(DiscovergyMixin, SensorEntityDescription): + """Define Sensor entity description class.""" + + +GAS_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( + DiscovergySensorEntityDescription( + key="volume", + translation_key="total_gas_consumption", + suggested_display_precision=4, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + +ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( + # power sensors + DiscovergySensorEntityDescription( + key="power", + translation_key="total_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + DiscovergySensorEntityDescription( + key="power1", + translation_key="phase_1_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + alternative_keys=["phase1Power"], + ), + DiscovergySensorEntityDescription( + key="power2", + translation_key="phase_2_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + alternative_keys=["phase2Power"], + ), + DiscovergySensorEntityDescription( + key="power3", + translation_key="phase_3_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + alternative_keys=["phase3Power"], + ), + # voltage sensors + DiscovergySensorEntityDescription( + key="phase1Voltage", + translation_key="phase_1_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + DiscovergySensorEntityDescription( + key="phase2Voltage", + translation_key="phase_2_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + DiscovergySensorEntityDescription( + key="phase3Voltage", + translation_key="phase_3_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + # energy sensors + DiscovergySensorEntityDescription( + key="energy", + translation_key="total_consumption", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=4, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + scale=10000000000, + ), + DiscovergySensorEntityDescription( + key="energyOut", + translation_key="total_production", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=4, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + scale=10000000000, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Discovergy sensors.""" + data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] + meters: list[Meter] = data.meters # always returns a list + + entities: list[DiscovergySensor] = [] + for meter in meters: + meter_id = meter.get_meter_id() + + sensors = None + if meter.measurement_type == "ELECTRICITY": + sensors = ELECTRICITY_SENSORS + elif meter.measurement_type == "GAS": + sensors = GAS_SENSORS + + if sensors is not None: + for description in sensors: + # check if this meter has this data, then add this sensor + for key in {description.key, *description.alternative_keys}: + coordinator: DiscovergyUpdateCoordinator = data.coordinators[ + meter_id + ] + if key in coordinator.data.values: + entities.append( + DiscovergySensor(key, description, meter, coordinator) + ) + + async_add_entities(entities, False) + + +class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEntity): + """Represents a discovergy smart meter sensor.""" + + entity_description: DiscovergySensorEntityDescription + data_key: str + _attr_has_entity_name = True + + def __init__( + self, + data_key: str, + description: DiscovergySensorEntityDescription, + meter: Meter, + coordinator: DiscovergyUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.data_key = data_key + + self.entity_description = description + self._attr_unique_id = f"{meter.full_serial_number}-{data_key}" + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, meter.get_meter_id())}, + ATTR_NAME: f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", + ATTR_MODEL: f"{meter.type} {meter.full_serial_number}", + ATTR_MANUFACTURER: MANUFACTURER, + } + + @property + def native_value(self) -> StateType: + """Return the sensor state.""" + return float( + self.coordinator.data.values[self.data_key] / self.entity_description.scale + ) diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json new file mode 100644 index 00000000000..e8dbbab2021 --- /dev/null +++ b/homeassistant/components/discovergy/strings.json @@ -0,0 +1,66 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "system_health": { + "info": { + "api_endpoint_reachable": "Discovergy API endpoint reachable" + } + }, + "entity": { + "sensor": { + "total_gas_consumption": { + "name": "Total gas consumption" + }, + "total_power": { + "name": "Total power" + }, + "total_consumption": { + "name": "Total consumption" + }, + "total_production": { + "name": "Total production" + }, + "phase_1_voltage": { + "name": "Phase 1 voltage" + }, + "phase_2_voltage": { + "name": "Phase 2 voltage" + }, + "phase_3_voltage": { + "name": "Phase 3 voltage" + }, + "phase_1_power": { + "name": "Phase 1 power" + }, + "phase_2_power": { + "name": "Phase 2 power" + }, + "phase_3_power": { + "name": "Phase 3 power" + } + } + } +} diff --git a/homeassistant/components/discovergy/system_health.py b/homeassistant/components/discovergy/system_health.py new file mode 100644 index 00000000000..2baeb0e5f6e --- /dev/null +++ b/homeassistant/components/discovergy/system_health.py @@ -0,0 +1,22 @@ +"""Provide info to system health.""" +from pydiscovergy.const import API_BASE + +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info) + + +async def system_health_info(hass): + """Get info for the info page.""" + return { + "api_endpoint_reachable": system_health.async_check_can_reach_url( + hass, API_BASE + ) + } diff --git a/homeassistant/components/dlib_face_detect/manifest.json b/homeassistant/components/dlib_face_detect/manifest.json index 33811d5821c..e395a84f206 100644 --- a/homeassistant/components/dlib_face_detect/manifest.json +++ b/homeassistant/components/dlib_face_detect/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dlib_face_detect", "iot_class": "local_push", "loggers": ["face_recognition"], - "requirements": ["face_recognition==1.2.3"] + "requirements": ["face-recognition==1.2.3"] } diff --git a/homeassistant/components/dlib_face_identify/manifest.json b/homeassistant/components/dlib_face_identify/manifest.json index 34cc7344cd9..60c0ef3c766 100644 --- a/homeassistant/components/dlib_face_identify/manifest.json +++ b/homeassistant/components/dlib_face_identify/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dlib_face_identify", "iot_class": "local_push", "loggers": ["face_recognition"], - "requirements": ["face_recognition==1.2.3"] + "requirements": ["face-recognition==1.2.3"] } diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 2fd1a85ebae..8fc55830c63 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -16,6 +16,7 @@ from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice from didl_lite import didl_lite from homeassistant.backports.enum import StrEnum +from homeassistant.backports.functools import cached_property from homeassistant.components import ssdp from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_source.error import Unresolvable @@ -619,7 +620,7 @@ class DmsDeviceSource: """Make an identifier for BrowseMediaSource.""" return f"{self.source_id}/{action}{object_id}" - @functools.cached_property + @cached_property def _sort_criteria(self) -> list[str]: """Return criteria to be used for sorting results. diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 8240bb117ea..c94fff1124e 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -350,14 +350,13 @@ class Doods(ImageProcessingEntity): or boxes[3] > self._area[3] ): continue - else: - if ( - boxes[0] > self._area[2] - or boxes[1] > self._area[3] - or boxes[2] < self._area[0] - or boxes[3] < self._area[1] - ): - continue + elif ( + boxes[0] > self._area[2] + or boxes[1] > self._area[3] + or boxes[2] < self._area[0] + or boxes[3] < self._area[1] + ): + continue # Exclude matches outside label specific area definition if self._label_areas.get(label): @@ -369,14 +368,13 @@ class Doods(ImageProcessingEntity): or boxes[3] > self._label_areas[label][3] ): continue - else: - if ( - boxes[0] > self._label_areas[label][2] - or boxes[1] > self._label_areas[label][3] - or boxes[2] < self._label_areas[label][0] - or boxes[3] < self._label_areas[label][1] - ): - continue + elif ( + boxes[0] > self._label_areas[label][2] + or boxes[1] > self._label_areas[label][3] + or boxes[2] < self._label_areas[label][0] + or boxes[3] < self._label_areas[label][1] + ): + continue if label not in matches: matches[label] = [] diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 79c114e2f38..52c89f3f34b 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "pillow==9.5.0"] + "requirements": ["pydoods==1.0.2", "Pillow==9.5.0"] } diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index d6eba115bb8..2bb981ab06f 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/doorbird", "iot_class": "local_push", "loggers": ["doorbirdpy"], - "requirements": ["doorbirdpy==2.1.0"], + "requirements": ["DoorBirdPy==2.1.0"], "zeroconf": [ { "type": "_axis-video._tcp.local.", diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index e21e35da1e5..6cfbdd50b34 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -39,13 +39,12 @@ class DormakabaDkeyBinarySensorDescription( BINARY_SENSOR_DESCRIPTIONS = ( DormakabaDkeyBinarySensorDescription( key="door_position", - name="Door", device_class=BinarySensorDeviceClass.DOOR, is_on=lambda state: state.door_position == DoorPosition.OPEN, ), DormakabaDkeyBinarySensorDescription( key="security_locked", - name="Deadbolt", + translation_key="deadbolt", device_class=BinarySensorDeviceClass.LOCK, is_on=lambda state: state.unlock_status not in (UnlockStatus.SECURITY_LOCKED, UnlockStatus.UNLOCKED_SECURITY_LOCKED), diff --git a/homeassistant/components/dormakaba_dkey/sensor.py b/homeassistant/components/dormakaba_dkey/sensor.py index 8234b41c43a..39915563b03 100644 --- a/homeassistant/components/dormakaba_dkey/sensor.py +++ b/homeassistant/components/dormakaba_dkey/sensor.py @@ -22,7 +22,6 @@ from .models import DormakabaDkeyData BINARY_SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key="battery_level", - name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index efe9d3acb52..15bcf3f9ddc 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -33,5 +33,12 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "binary_sensor": { + "deadbolt": { + "name": "Deadbolt" + } + } } } diff --git a/homeassistant/components/dremel_3d_printer/__init__.py b/homeassistant/components/dremel_3d_printer/__init__.py new file mode 100644 index 00000000000..5f9f10dc9c1 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/__init__.py @@ -0,0 +1,48 @@ +"""The Dremel 3D Printer (3D20, 3D40, 3D45) integration.""" +from __future__ import annotations + +from dremel3dpy import Dremel3DPrinter +from requests.exceptions import ConnectTimeout, HTTPError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CAMERA_MODEL, DOMAIN +from .coordinator import Dremel3DPrinterDataUpdateCoordinator + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Dremel 3D Printer from a config entry.""" + try: + api = await hass.async_add_executor_job( + Dremel3DPrinter, config_entry.data[CONF_HOST] + ) + + except (ConnectTimeout, HTTPError) as ex: + raise ConfigEntryNotReady( + f"Unable to connect to Dremel 3D Printer: {ex}" + ) from ex + + coordinator = Dremel3DPrinterDataUpdateCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + platforms = list(PLATFORMS) + if api.get_model() != CAMERA_MODEL: + platforms.remove(Platform.CAMERA) + await hass.config_entries.async_forward_entry_setups(config_entry, platforms) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Dremel config entry.""" + platforms = list(PLATFORMS) + api: Dremel3DPrinter = hass.data[DOMAIN][entry.entry_id].api + if api.get_model() != CAMERA_MODEL: + platforms.remove(Platform.CAMERA) + 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/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py new file mode 100644 index 00000000000..3a92bfe5510 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py @@ -0,0 +1,72 @@ +"""Support for monitoring Dremel 3D Printer binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import Dremel3DPrinterEntity + + +@dataclass +class Dremel3DPrinterBinarySensorEntityMixin: + """Mixin for Dremel 3D Printer binary sensor.""" + + value_fn: Callable[[Dremel3DPrinter], bool] + + +@dataclass +class Dremel3DPrinterBinarySensorEntityDescription( + BinarySensorEntityDescription, Dremel3DPrinterBinarySensorEntityMixin +): + """Describes a Dremel 3D Printer binary sensor.""" + + +BINARY_SENSOR_TYPES: tuple[Dremel3DPrinterBinarySensorEntityDescription, ...] = ( + Dremel3DPrinterBinarySensorEntityDescription( + key="door", + device_class=BinarySensorDeviceClass.DOOR, + value_fn=lambda api: api.is_door_open(), + ), + Dremel3DPrinterBinarySensorEntityDescription( + key="running", + device_class=BinarySensorDeviceClass.RUNNING, + value_fn=lambda api: api.is_running(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the available Dremel binary sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + Dremel3DPrinterBinarySensor(coordinator, description) + for description in BINARY_SENSOR_TYPES + ) + + +class Dremel3DPrinterBinarySensor(Dremel3DPrinterEntity, BinarySensorEntity): + """Representation of a Dremel 3D Printer door binary sensor.""" + + entity_description: Dremel3DPrinterBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return True if door is open.""" + return self.entity_description.value_fn(self._api) diff --git a/homeassistant/components/dremel_3d_printer/button.py b/homeassistant/components/dremel_3d_printer/button.py new file mode 100644 index 00000000000..2d328b30cea --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/button.py @@ -0,0 +1,78 @@ +"""Support for Dremel 3D Printer buttons.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import Dremel3DPrinterEntity + + +@dataclass +class Dremel3DPrinterButtonEntityMixin: + """Mixin for required keys.""" + + press_fn: Callable[[Dremel3DPrinter], None] + + +@dataclass +class Dremel3DPrinterButtonEntityDescription( + ButtonEntityDescription, Dremel3DPrinterButtonEntityMixin +): + """Describes a Dremel 3D Printer button entity.""" + + +BUTTON_TYPES: tuple[Dremel3DPrinterButtonEntityDescription, ...] = ( + Dremel3DPrinterButtonEntityDescription( + key="cancel_job", + translation_key="cancel_job", + press_fn=lambda api: api.stop_print(), + ), + Dremel3DPrinterButtonEntityDescription( + key="pause_job", + translation_key="pause_job", + press_fn=lambda api: api.pause_print(), + ), + Dremel3DPrinterButtonEntityDescription( + key="resume_job", + translation_key="resume_job", + press_fn=lambda api: api.resume_print(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Dremel 3D Printer control buttons.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + Dremel3DPrinterButtonEntity(coordinator, description) + for description in BUTTON_TYPES + ) + + +class Dremel3DPrinterButtonEntity(Dremel3DPrinterEntity, ButtonEntity): + """Represent a Dremel 3D Printer button.""" + + entity_description: Dremel3DPrinterButtonEntityDescription + + def press(self) -> None: + """Handle the button press.""" + # api does not care about the current state + try: + self.entity_description.press_fn(self._api) + except RuntimeError as ex: + raise HomeAssistantError( + "An error occurred while submitting command" + ) from ex diff --git a/homeassistant/components/dremel_3d_printer/camera.py b/homeassistant/components/dremel_3d_printer/camera.py new file mode 100644 index 00000000000..7468400ec35 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/camera.py @@ -0,0 +1,44 @@ +"""Support for Dremel 3D45 Camera.""" +from __future__ import annotations + +from homeassistant.components.camera import CameraEntityDescription +from homeassistant.components.mjpeg import MjpegCamera +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Dremel3DPrinterDataUpdateCoordinator +from .const import DOMAIN +from .entity import Dremel3DPrinterEntity + +CAMERA_TYPE = CameraEntityDescription( + key="camera", + name="Camera", +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a MJPEG IP Camera for the 3D45 Model. The 3D20 and 3D40 models don't have built in cameras.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([Dremel3D45Camera(coordinator, CAMERA_TYPE)]) + + +class Dremel3D45Camera(Dremel3DPrinterEntity, MjpegCamera): + """Dremel 3D45 Camera.""" + + def __init__( + self, + coordinator: Dremel3DPrinterDataUpdateCoordinator, + description: CameraEntityDescription, + ) -> None: + """Initialize a new Dremel 3D Printer integration camera for the 3D45 model.""" + super().__init__(coordinator, description) + MjpegCamera.__init__( + self, + mjpeg_url=coordinator.api.get_stream_url(), + still_image_url=coordinator.api.get_snapshot_url(), + ) diff --git a/homeassistant/components/dremel_3d_printer/config_flow.py b/homeassistant/components/dremel_3d_printer/config_flow.py new file mode 100644 index 00000000000..6fa4d2e0a5b --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Dremel 3D Printer (3D20, 3D40, 3D45).""" +from __future__ import annotations + +from json.decoder import JSONDecodeError +from typing import Any + +from dremel3dpy import Dremel3DPrinter +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, LOGGER + + +def _schema_with_defaults(host: str = "") -> vol.Schema: + return vol.Schema({vol.Required(CONF_HOST, default=host): cv.string}) + + +class Dremel3DPrinterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Dremel 3D Printer.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=_schema_with_defaults(), + ) + host = user_input[CONF_HOST] + + try: + api = await self.hass.async_add_executor_job(Dremel3DPrinter, host) + except (ConnectTimeout, HTTPError, JSONDecodeError): + errors = {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + LOGGER.exception("An unknown error has occurred") + errors = {"base": "unknown"} + + if errors: + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=_schema_with_defaults(host=host), + ) + + await self.async_set_unique_id(api.get_serial_number()) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=api.get_title(), data={CONF_HOST: host}) diff --git a/homeassistant/components/dremel_3d_printer/const.py b/homeassistant/components/dremel_3d_printer/const.py new file mode 100644 index 00000000000..cccdeb937cb --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/const.py @@ -0,0 +1,13 @@ +"""Constants for the Dremel 3D Printer (3D20, 3D40, 3D45) integration.""" +from __future__ import annotations + +import logging + +LOGGER = logging.getLogger(__package__) + +CAMERA_MODEL = "3D45" + +DOMAIN = "dremel_3d_printer" + +ATTR_EXTRUDER = "extruder" +ATTR_PLATFORM = "platform" diff --git a/homeassistant/components/dremel_3d_printer/coordinator.py b/homeassistant/components/dremel_3d_printer/coordinator.py new file mode 100644 index 00000000000..81e0053fd77 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/coordinator.py @@ -0,0 +1,36 @@ +"""Data update coordinator for the Dremel 3D Printer integration.""" + +from datetime import timedelta + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class Dremel3DPrinterDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Dremel 3D Printer data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, api: Dremel3DPrinter) -> None: + """Initialize Dremel 3D Printer data update coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=10), + ) + self.api = api + + async def _async_update_data(self) -> None: + """Update data via APIs.""" + try: + await self.hass.async_add_executor_job(self.api.refresh) + except RuntimeError as ex: + raise UpdateFailed( + f"Unable to refresh printer information: Printer offline: {ex}" + ) from ex diff --git a/homeassistant/components/dremel_3d_printer/entity.py b/homeassistant/components/dremel_3d_printer/entity.py new file mode 100644 index 00000000000..392869a138b --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/entity.py @@ -0,0 +1,41 @@ +"""Entity representing a Dremel 3D Printer.""" + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import Dremel3DPrinterDataUpdateCoordinator + + +class Dremel3DPrinterEntity(CoordinatorEntity[Dremel3DPrinterDataUpdateCoordinator]): + """Defines a Dremel 3D Printer device entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: Dremel3DPrinterDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the base device entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Dremel printer.""" + return DeviceInfo( + identifiers={(DOMAIN, self._api.get_serial_number())}, + manufacturer=self._api.get_manufacturer(), + model=self._api.get_model(), + name=self._api.get_title(), + sw_version=self._api.get_firmware_version(), + ) + + @property + def _api(self) -> Dremel3DPrinter: + """Return to api from coordinator.""" + return self.coordinator.api diff --git a/homeassistant/components/dremel_3d_printer/manifest.json b/homeassistant/components/dremel_3d_printer/manifest.json new file mode 100644 index 00000000000..12d4e4003c4 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "dremel_3d_printer", + "name": "Dremel 3D Printer", + "codeowners": ["@tkdrob"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/dremel_3d_printer", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["dremel3dpy==2.1.1"] +} diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py new file mode 100644 index 00000000000..660e7a90487 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -0,0 +1,279 @@ +"""Support for monitoring Dremel 3D Printer sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from dremel3dpy import Dremel3DPrinter + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfInformation, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow +from homeassistant.util.variance import ignore_variance + +from .const import ATTR_EXTRUDER, ATTR_PLATFORM, DOMAIN +from .entity import Dremel3DPrinterEntity + + +@dataclass +class Dremel3DPrinterSensorEntityMixin: + """Mixin for Dremel 3D Printer sensor.""" + + value_fn: Callable[[Dremel3DPrinter, str], StateType | datetime] + + +@dataclass +class Dremel3DPrinterSensorEntityDescription( + SensorEntityDescription, Dremel3DPrinterSensorEntityMixin +): + """Describes a Dremel 3D Printer sensor.""" + + available_fn: Callable[[Dremel3DPrinter, str], bool] = lambda api, _: True + + +SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( + Dremel3DPrinterSensorEntityDescription( + key="job_phase", + translation_key="job_phase", + icon="mdi:printer-3d", + value_fn=lambda api, _: api.get_printing_status(), + ), + Dremel3DPrinterSensorEntityDescription( + key="remaining_time", + translation_key="completion_time", + device_class=SensorDeviceClass.TIMESTAMP, + available_fn=lambda api, key: api.get_job_status()[key] > 0, + value_fn=ignore_variance( + lambda api, key: utcnow() + timedelta(seconds=api.get_job_status()[key]), + timedelta(minutes=2), + ), + ), + Dremel3DPrinterSensorEntityDescription( + key="progress", + translation_key="progress", + icon="mdi:printer-3d-nozzle", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_printing_progress(), + ), + Dremel3DPrinterSensorEntityDescription( + key="chamber", + translation_key="chamber", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_temperature_type(key), + ), + Dremel3DPrinterSensorEntityDescription( + key="platform_temperature", + translation_key="platform_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_type(ATTR_PLATFORM), + ), + Dremel3DPrinterSensorEntityDescription( + key="target_platform_temperature", + translation_key="target_platform_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_PLATFORM)[ + "target_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key="max_platform_temperature", + translation_key="max_platform_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_PLATFORM)[ + "max_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key=ATTR_EXTRUDER, + translation_key="extruder", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_temperature_type(key), + ), + Dremel3DPrinterSensorEntityDescription( + key="target_extruder_temperature", + translation_key="target_extruder_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_EXTRUDER)[ + "target_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key="max_extruder_temperature", + translation_key="max_extruder_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_temperature_attributes(ATTR_EXTRUDER)[ + "max_temp" + ], + ), + Dremel3DPrinterSensorEntityDescription( + key="network_build", + translation_key="network_build", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="filament", + translation_key="filament", + icon="mdi:printer-3d-nozzle", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="elapsed_time", + translation_key="elapsed_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + available_fn=lambda api, _: api.get_printing_status() == "building", + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="estimated_total_time", + translation_key="estimated_total_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + available_fn=lambda api, key: api.get_job_status()[key] > 0, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="job_status", + translation_key="job_status", + icon="mdi:printer-3d", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_job_status()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="job_name", + translation_key="job_name", + icon="mdi:file", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, _: api.get_job_name(), + ), + Dremel3DPrinterSensorEntityDescription( + key="api_version", + translation_key="api_version", + icon="mdi:api", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="host", + translation_key="host", + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="connection_type", + translation_key="connection_type", + icon="mdi:network", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), + Dremel3DPrinterSensorEntityDescription( + key="available_storage", + translation_key="available_storage", + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key] * 100, + ), + Dremel3DPrinterSensorEntityDescription( + key="hours_used", + translation_key="hours_used", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda api, key: api.get_printer_info()[key], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the available Dremel 3D Printer sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + Dremel3DPrinterSensor(coordinator, description) for description in SENSOR_TYPES + ) + + +class Dremel3DPrinterSensor(Dremel3DPrinterEntity, SensorEntity): + """Representation of a Dremel 3D Printer sensor.""" + + entity_description: Dremel3DPrinterSensorEntityDescription + + @property + def available(self) -> bool: + """Return True if the entity is available.""" + return super().available and self.entity_description.available_fn( + self._api, self.entity_description.key + ) + + @property + def native_value(self) -> StateType | datetime: + """Return the sensor state.""" + return self.entity_description.value_fn(self._api, self.entity_description.key) diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json new file mode 100644 index 00000000000..0016b8f2bca --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -0,0 +1,96 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "button": { + "cancel_job": { + "name": "Cancel job" + }, + "pause_job": { + "name": "Pause job" + }, + "resume_job": { + "name": "Resume job" + } + }, + "sensor": { + "job_phase": { + "name": "Job phase" + }, + "completion_time": { + "name": "Completion time" + }, + "progress": { + "name": "Progress" + }, + "chamber": { + "name": "Chamber" + }, + "platform_temperature": { + "name": "Platform temperature" + }, + "target_platform_temperature": { + "name": "Target platform temperature" + }, + "max_platform_temperature": { + "name": "Max platform temperature" + }, + "extruder": { + "name": "Extruder" + }, + "target_extruder_temperature": { + "name": "Target extruder temperature" + }, + "max_extruder_temperature": { + "name": "Max extruder temperature" + }, + "network_build": { + "name": "Network build" + }, + "filament": { + "name": "Filament" + }, + "elapsed_time": { + "name": "Elapsed time" + }, + "estimated_total_time": { + "name": "Estimated total time" + }, + "job_status": { + "name": "Job status" + }, + "job_name": { + "name": "Job name" + }, + "api_version": { + "name": "API version" + }, + "host": { + "name": "[%key:common::config_flow::data::host%]" + }, + "connection_type": { + "name": "Connection type" + }, + "available_storage": { + "name": "Available storage" + }, + "hours_used": { + "name": "Hours used" + } + } + } +} diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 2ba7ce55835..3fc81d2f8e7 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==0.33"] } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 524f5c4ffc2..e6d1d035e3b 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -459,9 +459,9 @@ async def async_setup_entry( @callback def close_transport(_event: EventType) -> None: """Close the transport on HA shutdown.""" - if not transport: + if not transport: # noqa: B023 return - transport.close() + transport.close() # noqa: B023 stop_listener = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, close_transport diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 3e8ed2afbdc..f44d736b426 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -91,7 +91,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index a3dd890cc11..8fd138dc49b 100644 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/dynalite", "iot_class": "local_push", "loggers": ["dynalite_devices_lib"], - "requirements": ["dynalite_devices==0.1.47", "dynalite_panel==0.0.4"] + "requirements": ["dynalite-devices==0.1.47", "dynalite-panel==0.0.4"] } diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index 2650aa35489..ce3ee2bfbec 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -100,6 +100,7 @@ class Measurement(CoordinatorEntity, SensorEntity): """Initialise the gauge with a data instance and station.""" super().__init__(coordinator) self.key = key + self._attr_unique_id = key @property def station_name(self): @@ -126,11 +127,6 @@ class Measurement(CoordinatorEntity, SensorEntity): """Return the name of the gauge.""" return f"{self.station_name} {self.parameter_name} {self.qualifier}" - @property - def unique_id(self): - """Return the unique id of the gauge.""" - return self.key - @property def device_info(self): """Return the device info.""" diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 9cf5944dfaa..a64851f6696 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -48,7 +48,7 @@ class EasyEnergySensorEntityDescription( SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( EasyEnergySensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_gas", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", @@ -56,14 +56,14 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_gas", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", value_fn=lambda data: get_gas_price(data, 1), ), EasyEnergySensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_energy_usage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", @@ -71,7 +71,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.price_at_time( @@ -80,42 +80,42 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="average_price", - name="Average - today", + translation_key="average_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.average_usage_price, ), EasyEnergySensorEntityDescription( key="max_price", - name="Highest price - today", + translation_key="max_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_usage_prices[1], ), EasyEnergySensorEntityDescription( key="min_price", - name="Lowest price - today", + translation_key="min_price", service_type="today_energy_usage", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_usage_prices[0], ), EasyEnergySensorEntityDescription( key="highest_price_time", - name="Time of highest price - today", + translation_key="highest_price_time", service_type="today_energy_usage", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.highest_usage_price_time, ), EasyEnergySensorEntityDescription( key="lowest_price_time", - name="Time of lowest price - today", + translation_key="lowest_price_time", service_type="today_energy_usage", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.lowest_usage_price_time, ), EasyEnergySensorEntityDescription( key="percentage_of_max", - name="Current percentage of highest price - today", + translation_key="percentage_of_max", service_type="today_energy_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", @@ -123,7 +123,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_energy_return", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", @@ -131,7 +131,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.price_at_time( @@ -140,42 +140,42 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="average_price", - name="Average - today", + translation_key="average_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.average_return_price, ), EasyEnergySensorEntityDescription( key="max_price", - name="Highest price - today", + translation_key="max_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_return_prices[1], ), EasyEnergySensorEntityDescription( key="min_price", - name="Lowest price - today", + translation_key="min_price", service_type="today_energy_return", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_return_prices[0], ), EasyEnergySensorEntityDescription( key="highest_price_time", - name="Time of highest price - today", + translation_key="highest_price_time", service_type="today_energy_return", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.highest_return_price_time, ), EasyEnergySensorEntityDescription( key="lowest_price_time", - name="Time of lowest price - today", + translation_key="lowest_price_time", service_type="today_energy_return", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.lowest_return_price_time, ), EasyEnergySensorEntityDescription( key="percentage_of_max", - name="Current percentage of highest price - today", + translation_key="percentage_of_max", service_type="today_energy_return", native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", @@ -183,7 +183,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="hours_priced_equal_or_lower", - name="Hours priced equal or lower than current - today", + translation_key="hours_priced_equal_or_lower", service_type="today_energy_usage", native_unit_of_measurement=UnitOfTime.HOURS, icon="mdi:clock", @@ -191,7 +191,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = ( ), EasyEnergySensorEntityDescription( key="hours_priced_equal_or_higher", - name="Hours priced equal or higher than current - today", + translation_key="hours_priced_equal_or_higher", service_type="today_energy_return", native_unit_of_measurement=UnitOfTime.HOURS, icon="mdi:clock", @@ -231,7 +231,7 @@ async def async_setup_entry( class EasyEnergySensorEntity( CoordinatorEntity[EasyEnergyDataUpdateCoordinator], SensorEntity ): - """Defines a easyEnergy sensor.""" + """Defines an easyEnergy sensor.""" _attr_has_entity_name = True _attr_attribution = "Data provided by easyEnergy" diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index ed89e0068d4..93fb264b01d 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -8,5 +8,39 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "current_hour_price": { + "name": "Current hour" + }, + "next_hour_price": { + "name": "Next hour" + }, + "average_price": { + "name": "Average - today" + }, + "max_price": { + "name": "Highest price - today" + }, + "min_price": { + "name": "Lowest price - today" + }, + "highest_price_time": { + "name": "Time of highest price - today" + }, + "lowest_price_time": { + "name": "Time of lowest price - today" + }, + "percentage_of_max": { + "name": "Current percentage of highest price - today" + }, + "hours_priced_equal_or_lower": { + "name": "Hours priced equal or lower than current - today" + }, + "hours_priced_equal_or_higher": { + "name": "Hours priced equal or higher than current - today" + } + } } } diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 2266d70e0ad..e65dc221a9f 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -35,19 +35,16 @@ async def async_setup_entry( class EcobeeBinarySensor(BinarySensorEntity): """Representation of an Ecobee sensor.""" + _attr_device_class = BinarySensorDeviceClass.OCCUPANCY + _attr_has_entity_name = True + def __init__(self, data, sensor_name, sensor_index): """Initialize the Ecobee sensor.""" self.data = data - self._name = f"{sensor_name} Occupancy" - self.sensor_name = sensor_name + self.sensor_name = sensor_name.rstrip() self.index = sensor_index self._state = None - @property - def name(self): - """Return the name of the Ecobee sensor.""" - return self._name.rstrip() - @property def unique_id(self): """Return a unique identifier for this sensor.""" @@ -101,11 +98,6 @@ class EcobeeBinarySensor(BinarySensorEntity): """Return the status of the sensor.""" return self._state == "true" - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return BinarySensorDeviceClass.OCCUPANCY - async def async_update(self) -> None: """Get the latest state of the sensor.""" await self.data.update() diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 7925832953b..8c0b77b913d 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -310,6 +310,8 @@ class Thermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_name = None + _attr_has_entity_name = True def __init__( self, data: EcobeeData, thermostat_index: int, thermostat: dict @@ -318,7 +320,7 @@ class Thermostat(ClimateEntity): self.data = data self.thermostat_index = thermostat_index self.thermostat = thermostat - self._name = self.thermostat["name"] + self._attr_unique_id = self.thermostat["identifier"] self.vacation = None self._last_active_hvac_mode = HVACMode.HEAT_COOL @@ -364,16 +366,6 @@ class Thermostat(ClimateEntity): supported = supported | ClimateEntityFeature.AUX_HEAT return supported - @property - def name(self): - """Return the name of the Ecobee Thermostat.""" - return self.thermostat["name"] - - @property - def unique_id(self): - """Return a unique identifier for this ecobee thermostat.""" - return self.thermostat["identifier"] - @property def device_info(self) -> DeviceInfo: """Return device information for this ecobee thermostat.""" @@ -388,7 +380,7 @@ class Thermostat(ClimateEntity): identifiers={(DOMAIN, self.thermostat["identifier"])}, manufacturer=MANUFACTURER, model=model, - name=self.name, + name=self.thermostat["name"], ) @property diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py index f4c4dad6527..fb5533adf07 100644 --- a/homeassistant/components/ecobee/humidifier.py +++ b/homeassistant/components/ecobee/humidifier.py @@ -44,27 +44,19 @@ class EcobeeHumidifier(HumidifierEntity): """A humidifier class for an ecobee thermostat with humidifier attached.""" _attr_supported_features = HumidifierEntityFeature.MODES + _attr_has_entity_name = True + _attr_name = None def __init__(self, data, thermostat_index): """Initialize ecobee humidifier platform.""" self.data = data self.thermostat_index = thermostat_index self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) - self._name = self.thermostat["name"] + self._attr_unique_id = self.thermostat["identifier"] self._last_humidifier_on_mode = MODE_MANUAL self.update_without_throttle = False - @property - def name(self): - """Return the name of the humidifier.""" - return self._name - - @property - def unique_id(self): - """Return unique_id for humidifier.""" - return f"{self.thermostat['identifier']}" - @property def device_info(self) -> DeviceInfo: """Return device information for the ecobee humidifier.""" @@ -79,7 +71,7 @@ class EcobeeHumidifier(HumidifierEntity): identifiers={(DOMAIN, self.thermostat["identifier"])}, manufacturer=MANUFACTURER, model=model, - name=self.name, + name=self.thermostat["name"], ) @property diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index 15ad17b0e39..67c975010ab 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -36,7 +36,7 @@ class EcobeeNumberEntityDescription( VENTILATOR_NUMBERS = ( EcobeeNumberEntityDescription( key="home", - name="home", + translation_key="ventilator_min_type_home", ecobee_setting_key="ventilatorMinOnTimeHome", set_fn=lambda data, id, min_time: data.ecobee.set_ventilator_min_on_time_home( id, min_time @@ -44,7 +44,7 @@ VENTILATOR_NUMBERS = ( ), EcobeeNumberEntityDescription( key="away", - name="away", + translation_key="ventilator_min_type_away", ecobee_setting_key="ventilatorMinOnTimeAway", set_fn=lambda data, id, min_time: data.ecobee.set_ventilator_min_on_time_away( id, min_time @@ -92,7 +92,6 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): """Initialize ecobee ventilator platform.""" super().__init__(data, thermostat_index) self.entity_description = description - self._attr_name = f"Ventilator min time {description.name}" self._attr_unique_id = f"{self.base_unique_id}_ventilator_{description.key}" async def async_update(self) -> None: diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 90d4ba4202e..3996ec6fd35 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -42,7 +42,6 @@ class EcobeeSensorEntityDescription( SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( EcobeeSensorEntityDescription( key="temperature", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -50,7 +49,6 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( ), EcobeeSensorEntityDescription( key="humidity", - name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -58,7 +56,6 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( ), EcobeeSensorEntityDescription( key="co2PPM", - name="CO2", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, @@ -66,7 +63,6 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( ), EcobeeSensorEntityDescription( key="vocPPM", - name="VOC", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -74,7 +70,6 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( ), EcobeeSensorEntityDescription( key="airQuality", - name="Air Quality Index", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, runtime_key="actualAQScore", @@ -104,6 +99,8 @@ async def async_setup_entry( class EcobeeSensor(SensorEntity): """Representation of an Ecobee sensor.""" + _attr_has_entity_name = True + entity_description: EcobeeSensorEntityDescription def __init__( @@ -119,7 +116,6 @@ class EcobeeSensor(SensorEntity): self.sensor_name = sensor_name self.index = sensor_index self._state = None - self._attr_name = f"{sensor_name} {description.name}" @property def unique_id(self): diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 19f379de7d9..647ea55e311 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -2,14 +2,12 @@ "config": { "step": { "user": { - "title": "ecobee API key", "description": "Please enter the API key obtained from ecobee.com.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } }, "authorize": { - "title": "Authorize app on ecobee.com", "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, press Submit." } }, @@ -20,5 +18,15 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "number": { + "ventilator_min_type_home": { + "name": "Ventilator min time home" + }, + "ventilator_min_type_away": { + "name": "Ventilator min time away" + } + } } } diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index 5610cdb2a9c..d38bc82c6f2 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -57,6 +57,8 @@ class EcobeeWeather(WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_native_visibility_unit = UnitOfLength.METERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_has_entity_name = True + _attr_name = None def __init__(self, data, name, index): """Initialize the Ecobee weather platform.""" @@ -64,6 +66,7 @@ class EcobeeWeather(WeatherEntity): self._name = name self._index = index self.weather = None + self._attr_unique_id = data.ecobee.get_thermostat(self._index)["identifier"] def get_forecast(self, index, param): """Retrieve forecast parameter.""" @@ -73,16 +76,6 @@ class EcobeeWeather(WeatherEntity): except (IndexError, KeyError) as err: raise ValueError from err - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return a unique identifier for the weather platform.""" - return self.data.ecobee.get_thermostat(self._index)["identifier"] - @property def device_info(self) -> DeviceInfo: """Return device information for the ecobee weather platform.""" @@ -98,7 +91,7 @@ class EcobeeWeather(WeatherEntity): identifiers={(DOMAIN, thermostat["identifier"])}, manufacturer=MANUFACTURER, model=model, - name=self.name, + name=self._name, ) @property diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 6fa54fc70fb..afba9ba6837 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -19,7 +19,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType from .const import API_CLIENT, DOMAIN, EQUIPMENT @@ -36,14 +35,6 @@ PUSH_UPDATE = "econet.push_update" INTERVAL = timedelta(minutes=60) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the EcoNet component.""" - hass.data[DOMAIN] = {} - hass.data[DOMAIN][API_CLIENT] = {} - hass.data[DOMAIN][EQUIPMENT] = {} - return True - - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up EcoNet as config entry.""" @@ -65,6 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) except (ClientError, GenericHTTPError, InvalidResponseFormat) as err: raise ConfigEntryNotReady from err + hass.data.setdefault(DOMAIN, {API_CLIENT: {}, EQUIPMENT: {}}) hass.data[DOMAIN][API_CLIENT][config_entry.entry_id] = api hass.data[DOMAIN][EQUIPMENT][config_entry.entry_id] = equipment diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index cf950a3c38c..7233d135f2e 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -151,7 +151,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): return self.op_list @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool, mode. Needs to be one of HVAC_MODE_*. diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 5bbe22b6972..8d5411e9e2e 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -125,7 +125,7 @@ ECOWITT_SENSORS_MAPPING: Final = { EcoWittSensorTypes.LIGHTNING_COUNT: SensorEntityDescription( key="LIGHTNING_COUNT", native_unit_of_measurement="strikes", - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, ), EcoWittSensorTypes.TEMPERATURE_C: SensorEntityDescription( key="TEMPERATURE_C", @@ -143,13 +143,13 @@ ECOWITT_SENSORS_MAPPING: Final = { key="RAIN_COUNT_MM", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, ), EcoWittSensorTypes.RAIN_COUNT_INCHES: SensorEntityDescription( key="RAIN_COUNT_INCHES", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, ), EcoWittSensorTypes.RAIN_RATE_MM: SensorEntityDescription( key="RAIN_RATE_MM", @@ -230,6 +230,13 @@ async def async_setup_entry( name=sensor.name, ) + # Hourly rain doesn't reset to fixed hours, it must be measurement state classes + if sensor.key in ("hrain_piezomm", "hrain_piezo"): + description = dataclasses.replace( + description, + state_class=SensorStateClass.MEASUREMENT, + ) + async_add_entities([EcowittSensorEntity(sensor, description)]) ecowitt.new_sensor_cb.append(_new_sensor) diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json index dba5d35ab1a..b15a88d099f 100644 --- a/homeassistant/components/eddystone_temperature/manifest.json +++ b/homeassistant/components/eddystone_temperature/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/eddystone_temperature", "iot_class": "local_polling", "loggers": ["beacontools"], - "requirements": ["beacontools[scan]==2.1.0", "construct==2.10.56"] + "requirements": ["beacontools[scan]==2.1.0"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index c2436c15057..3ce42198fbd 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, DEGREE, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -51,41 +50,47 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0 Ownership ID SensorEntityDescription( key="1-0:0.0.0*255", - name="Ownership ID", + translation_key="ownership_id", icon="mdi:flash", entity_registry_enabled_default=False, ), # E=9: Electrity ID SensorEntityDescription( - key="1-0:0.0.9*255", name="Electricity ID", icon="mdi:flash" + key="1-0:0.0.9*255", + translation_key="electricity_id", + icon="mdi:flash", ), # D=2: Program entries SensorEntityDescription( - key="1-0:0.2.0*0", name="Configuration program version number", icon="mdi:flash" + key="1-0:0.2.0*0", + translation_key="configuration_program_version_number", + icon="mdi:flash", ), SensorEntityDescription( - key="1-0:0.2.0*1", name="Firmware version number", icon="mdi:flash" + key="1-0:0.2.0*1", + translation_key="firmware_version_number", + icon="mdi:flash", ), # C=1: Active power + # D=8: Time integral 1 # E=0: Total SensorEntityDescription( key="1-0:1.8.0*255", - name="Positive active energy total", + translation_key="positive_active_energy_total", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), # E=1: Rate 1 SensorEntityDescription( key="1-0:1.8.1*255", - name="Positive active energy in tariff T1", + translation_key="positive_active_energy_tariff_t1", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), # E=2: Rate 2 SensorEntityDescription( key="1-0:1.8.2*255", - name="Positive active energy in tariff T2", + translation_key="positive_active_energy_tariff_t2", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), @@ -93,28 +98,28 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:1.17.0*255", - name="Last signed positive active energy total", + translation_key="last_signed_positive_active_energy_total", ), # C=2: Active power - # D=8: Time integral 1 # E=0: Total SensorEntityDescription( key="1-0:2.8.0*255", - name="Negative active energy total", + translation_key="negative_active_energy_total", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), # E=1: Rate 1 SensorEntityDescription( key="1-0:2.8.1*255", - name="Negative active energy in tariff T1", + translation_key="negative_active_energy_tariff_t1", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), # E=2: Rate 2 SensorEntityDescription( key="1-0:2.8.2*255", - name="Negative active energy in tariff T2", + translation_key="negative_active_energy_tariff_t2", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), @@ -122,14 +127,16 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # D=7: Instantaneous value # E=0: Total SensorEntityDescription( - key="1-0:14.7.0*255", name="Supply frequency", icon="mdi:sine-wave" + key="1-0:14.7.0*255", + translation_key="supply_frequency", + icon="mdi:sine-wave", ), # C=15: Active power absolute # D=7: Instantaneous value # E=0: Total SensorEntityDescription( key="1-0:15.7.0*255", - name="Absolute active instantaneous power", + translation_key="absolute_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -138,7 +145,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:16.7.0*255", - name="Sum active instantaneous power", + translation_key="sum_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -147,7 +154,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:31.7.0*255", - name="L1 active instantaneous amperage", + translation_key="l1_active_instantaneous_amperage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, ), @@ -156,7 +163,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:32.7.0*255", - name="L1 active instantaneous voltage", + translation_key="l1_active_instantaneous_voltage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, ), @@ -165,7 +172,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:36.7.0*255", - name="L1 active instantaneous power", + translation_key="l1_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -174,7 +181,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:51.7.0*255", - name="L2 active instantaneous amperage", + translation_key="l2_active_instantaneous_amperage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, ), @@ -183,7 +190,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:52.7.0*255", - name="L2 active instantaneous voltage", + translation_key="l2_active_instantaneous_voltage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, ), @@ -192,7 +199,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:56.7.0*255", - name="L2 active instantaneous power", + translation_key="l2_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -201,7 +208,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:71.7.0*255", - name="L3 active instantaneous amperage", + translation_key="l3_active_instantaneous_amperage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, ), @@ -210,7 +217,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:72.7.0*255", - name="L3 active instantaneous voltage", + translation_key="l3_active_instantaneous_voltage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, ), @@ -219,7 +226,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=0: Total SensorEntityDescription( key="1-0:76.7.0*255", - name="L3 active instantaneous power", + translation_key="l3_active_instantaneous_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), @@ -231,26 +238,40 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # E=15: U(L2) x I(L2) # E=26: U(L3) x I(L3) SensorEntityDescription( - key="1-0:81.7.1*255", name="U(L2)/U(L1) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.1*255", + translation_key="u_l2_u_l1_phase_angle", + icon="mdi:sine-wave", ), SensorEntityDescription( - key="1-0:81.7.2*255", name="U(L3)/U(L1) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.2*255", + translation_key="u_l3_u_l1_phase_angle", + icon="mdi:sine-wave", ), SensorEntityDescription( - key="1-0:81.7.4*255", name="U(L1)/I(L1) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.4*255", + translation_key="u_l1_i_l1_phase_angle", + icon="mdi:sine-wave", ), SensorEntityDescription( - key="1-0:81.7.15*255", name="U(L2)/I(L2) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.15*255", + translation_key="u_l2_i_l2_phase_angle", + icon="mdi:sine-wave", ), SensorEntityDescription( - key="1-0:81.7.26*255", name="U(L3)/I(L3) phase angle", icon="mdi:sine-wave" + key="1-0:81.7.26*255", + translation_key="u_l3_i_l3_phase_angle", + icon="mdi:sine-wave", ), # C=96: Electricity-related service entries SensorEntityDescription( - key="1-0:96.1.0*255", name="Metering point ID 1", icon="mdi:flash" + key="1-0:96.1.0*255", + translation_key="metering_point_id_1", + icon="mdi:flash", ), SensorEntityDescription( - key="1-0:96.5.0*255", name="Internal operating status", icon="mdi:flash" + key="1-0:96.5.0*255", + translation_key="internal_operating_status", + icon="mdi:flash", ), ) @@ -304,12 +325,11 @@ class EDL21: self._registered_obis: set[tuple[str, str]] = set() self._hass = hass self._async_add_entities = async_add_entities - self._name = config.get(CONF_NAME) + self._serial_port = config[CONF_SERIAL_PORT] self._proto = SmlProtocol(config[CONF_SERIAL_PORT]) self._proto.add_listener(self.event, ["SmlGetListResponse"]) LOGGER.debug( - "Initialized EDL21 for %s on %s", - config.get(CONF_NAME), + "Initialized EDL21 on %s", config[CONF_SERIAL_PORT], ) @@ -320,12 +340,14 @@ class EDL21: def event(self, message_body) -> None: """Handle events from pysml.""" assert isinstance(message_body, SmlGetListResponse) - LOGGER.debug("Received sml message for %s: %s", self._name, message_body) + LOGGER.debug("Received sml message on %s: %s", self._serial_port, message_body) electricity_id = message_body["serverId"] if electricity_id is None: - LOGGER.debug("No electricity id found in sml message for %s", self._name) + LOGGER.debug( + "No electricity id found in sml message on %s", self._serial_port + ) return electricity_id = electricity_id.replace(" ", "") @@ -340,15 +362,11 @@ class EDL21: ) else: entity_description = SENSORS.get(obis) - if entity_description and entity_description.name: - # self._name is only used for backwards YAML compatibility - # This needs to be cleaned up when YAML support is removed - device_name = self._name or DEFAULT_DEVICE_NAME + if entity_description: new_entities.append( EDL21Entity( electricity_id, obis, - device_name, entity_description, telegram, ) @@ -372,7 +390,7 @@ class EDL21Entity(SensorEntity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, electricity_id, obis, device_name, entity_description, telegram): + def __init__(self, electricity_id, obis, entity_description, telegram): """Initialize an EDL21Entity.""" self._electricity_id = electricity_id self._obis = obis @@ -384,7 +402,7 @@ class EDL21Entity(SensorEntity): self._attr_unique_id = f"{electricity_id}_{obis}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._electricity_id)}, - name=device_name, + name=DEFAULT_DEVICE_NAME, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json index 764cc41d2a4..43978642943 100644 --- a/homeassistant/components/edl21/strings.json +++ b/homeassistant/components/edl21/strings.json @@ -11,5 +11,99 @@ } } } + }, + "entity": { + "sensor": { + "ownership_id": { + "name": "Ownership ID" + }, + "electricity_id": { + "name": "Electricity ID" + }, + "configuration_program_version_number": { + "name": "Configuration program version number" + }, + "firmware_version_number": { + "name": "Firmware version number" + }, + "positive_active_energy_total": { + "name": "Positive active energy total" + }, + "positive_active_energy_tariff_t1": { + "name": "Positive active energy in tariff T1" + }, + "positive_active_energy_tariff_t2": { + "name": "Positive active energy in tariff T2" + }, + "last_signed_positive_active_energy_total": { + "name": "Last signed positive active energy total" + }, + "negative_active_energy_total": { + "name": "Negative active energy total" + }, + "negative_active_energy_tariff_t1": { + "name": "Negative active energy in tariff T1" + }, + "negative_active_energy_tariff_t2": { + "name": "Negative active energy in tariff T2" + }, + "supply_frequency": { + "name": "Supply frequency" + }, + "absolute_active_instantaneous_power": { + "name": "Absolute active instantaneous power" + }, + "sum_active_instantaneous_power": { + "name": "Sum active instantaneous power" + }, + "l1_active_instantaneous_amperage": { + "name": "L1 active instantaneous amperage" + }, + "l1_active_instantaneous_voltage": { + "name": "L1 active instantaneous voltage" + }, + "l1_active_instantaneous_power": { + "name": "L1 active instantaneous power" + }, + "l2_active_instantaneous_amperage": { + "name": "L2 active instantaneous amperage" + }, + "l2_active_instantaneous_voltage": { + "name": "L2 active instantaneous voltage" + }, + "l2_active_instantaneous_power": { + "name": "L2 active instantaneous power" + }, + "l3_active_instantaneous_amperage": { + "name": "L3 active instantaneous amperage" + }, + "l3_active_instantaneous_voltage": { + "name": "L3 active instantaneous voltage" + }, + "l3_active_instantaneous_power": { + "name": "L3 active instantaneous power" + }, + "u_l2_u_l1_phase_angle": { + "name": "U(L2)/U(L1) phase angle" + }, + "u_l3_u_l1_phase_angle": { + "name": "U(L3)/U(L1) phase angle" + }, + "u_l1_i_l1_phase_angle": { + "name": "U(L1)/I(L1) phase angle" + }, + "u_l2_i_l2_phase_angle": { + "name": "U(L2)/I(L2) phase angle" + }, + "u_l3_i_l3_phase_angle": { + "name": "U(L3)/I(L3) phase angle" + }, + "metering_point_id_1": { + "name": "Metering point ID 1" + }, + "internal_operating_status": { + "name": "Internal operating status" + } + } } } diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 1f544a7a97b..6fc6eed40f6 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -25,14 +25,14 @@ from .const import CONF_CURRENT_VALUES, DOMAIN, LOGGER SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="instant_readings", - name="Power Usage", + translation_key="instant_readings", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="energy_day", - name="Daily Consumption", + translation_key="energy_day", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -40,7 +40,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="energy_week", - name="Weekly Consumption", + translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -48,14 +48,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="energy_month", - name="Monthly Consumption", + translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="energy_year", - name="Yearly Consumption", + translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -63,32 +63,32 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="budget", - name="Energy Budget", + translation_key="budget", entity_registry_enabled_default=False, ), SensorEntityDescription( key="cost_day", - name="Daily Energy Cost", + translation_key="cost_day", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), SensorEntityDescription( key="cost_week", - name="Weekly Energy Cost", + translation_key="cost_week", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), SensorEntityDescription( key="cost_month", - name="Monthly Energy Cost", + translation_key="cost_month", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="cost_year", - name="Yearly Energy Cost", + translation_key="cost_year", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -137,6 +137,8 @@ async def async_setup_entry( class EfergySensor(EfergyEntity, SensorEntity): """Implementation of an Efergy sensor.""" + _attr_has_entity_name = True + def __init__( self, api: Efergy, diff --git a/homeassistant/components/efergy/strings.json b/homeassistant/components/efergy/strings.json index 924d5a56bcf..3b17bf07f1a 100644 --- a/homeassistant/components/efergy/strings.json +++ b/homeassistant/components/efergy/strings.json @@ -16,5 +16,39 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "instant_readings": { + "name": "Power usage" + }, + "energy_day": { + "name": "Daily consumption" + }, + "energy_week": { + "name": "Weekly consumption" + }, + "energy_month": { + "name": "Monthly consumption" + }, + "energy_year": { + "name": "Yearly consumption" + }, + "budget": { + "name": "Energy budget" + }, + "cost_day": { + "name": "Daily energy cost" + }, + "cost_week": { + "name": "Weekly energy cost" + }, + "cost_month": { + "name": "Monthly energy cost" + }, + "cost_year": { + "name": "Yearly energy cost" + } + } } } diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index b95e24823d6..71e01f75d46 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/eight_sleep", "iot_class": "cloud_polling", "loggers": ["pyeight"], - "requirements": ["pyeight==0.3.2"] + "requirements": ["pyEight==0.3.2"] } diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index a9688939048..59523d5a4cb 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -109,15 +109,18 @@ class ElectraClimateEntity(ClimateEntity): _attr_min_temp = MIN_TEMP _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = ELECTRA_MODES + _attr_has_entity_name = True + _attr_name = None def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: """Initialize Electra climate entity.""" self._api = api self._electra_ac_device = device - self._attr_name = device.name self._attr_unique_id = device.mac self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE ) swing_modes: list = [] @@ -140,7 +143,7 @@ class ElectraClimateEntity(ClimateEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._electra_ac_device.mac)}, - name=self.name, + name=device.name, model=self._electra_ac_device.model, manufacturer=self._electra_ac_device.manufactor, ) diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json index a2a3f928eeb..405d9ee688a 100644 --- a/homeassistant/components/electrasmart/manifest.json +++ b/homeassistant/components/electrasmart/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/electrasmart", "iot_class": "cloud_polling", - "requirements": ["pyelectra==1.2.0"] + "requirements": ["pyElectra==1.2.0"] } diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 97673a79b9a..b05cd532c16 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -40,14 +40,12 @@ class ElgatoButtonEntityDescription( BUTTONS = [ ElgatoButtonEntityDescription( key="identify", - translation_key="identify", - icon="mdi:help", + device_class=ButtonDeviceClass.IDENTIFY, entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.identify(), ), ElgatoButtonEntityDescription( key="restart", - translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.restart(), diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 47da87306a3..f74ec04476f 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -47,6 +47,7 @@ async def async_setup_entry( class ElgatoLight(ElgatoEntity, LightEntity): """Defines an Elgato Light.""" + _attr_name = None _attr_min_mireds = 143 _attr_max_mireds = 344 diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 371840de013..8ed8265705c 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -45,7 +45,6 @@ class ElgatoSensorEntityDescription( SENSORS = [ ElgatoSensorEntityDescription( key="battery", - translation_key="battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index c5fc016aeb9..8a2f20f209f 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -23,18 +23,7 @@ } }, "entity": { - "button": { - "identify": { - "name": "Identify" - }, - "restart": { - "name": "[%key:component::button::entity_component::restart::name%]" - } - }, "sensor": { - "battery": { - "name": "[%key:component::sensor::entity_component::battery::name%]" - }, "charge_power": { "name": "Charging power" }, diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 570c8567403..d0094a5b37b 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -85,7 +85,7 @@ class ElkThermostat(ElkEntity, ClimateEntity): def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: """Initialize climate entity.""" super().__init__(element, elk, elk_data) - self._state: str | None = None + self._state: HVACMode | None = None @property def temperature_unit(self) -> str: @@ -130,7 +130,7 @@ class ElkThermostat(ElkEntity, ClimateEntity): return self._element.humidity @property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" return self._state diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index e6e8d76be91..dfb90763c83 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax_api==0.0.4"] + "requirements": ["elmax-api==0.0.4"] } diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json index 872b3cca1e1..f90dda79352 100644 --- a/homeassistant/components/emby/manifest.json +++ b/homeassistant/components/emby/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/emby", "iot_class": "local_push", "loggers": ["pyemby"], - "requirements": ["pyemby==1.8"] + "requirements": ["pyEmby==1.9"] } diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index dc7159001d8..0cf4f0f2346 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -80,7 +80,7 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): mac_address = self.emonitor_status.network.mac_address device_name = name_short_mac(mac_address[-6:]) label = self.channel_data.label or f"{device_name} {channel_number}" - if description.name: + if description.name is not UNDEFINED: self._attr_name = f"{label} {description.name}" self._attr_unique_id = f"{mac_address}_{channel_number}_{description.key}" else: diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index ff3591e0066..01dae2dca77 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp_cors==0.7.0"] + "requirements": ["aiohttp-cors==0.7.0"] } diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index eea3f18adc0..324279db7d9 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense_energy==0.11.2"] + "requirements": ["sense_energy==0.12.0"] } diff --git a/homeassistant/components/emulated_roku/manifest.json b/homeassistant/components/emulated_roku/manifest.json index 960b3d41f63..739f3b04ec0 100644 --- a/homeassistant/components/emulated_roku/manifest.json +++ b/homeassistant/components/emulated_roku/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_roku", "iot_class": "local_push", "loggers": ["emulated_roku"], - "requirements": ["emulated_roku==0.2.1"] + "requirements": ["emulated-roku==0.2.1"] } diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 7518b163f3c..ae92ee2de58 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -434,6 +434,7 @@ class EnergyCostSensor(SensorEntity): def add_to_platform_abort(self) -> None: """Abort adding an entity to a platform.""" self.add_finished.set() + super().add_to_platform_abort() async def async_will_remove_from_hass(self) -> None: """Handle removing from hass.""" diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 75b5fa6fea6..17052dfab57 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -42,7 +42,7 @@ class EnergyZeroSensorEntityDescription( SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( EnergyZeroSensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_gas", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", @@ -50,14 +50,14 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( ), EnergyZeroSensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_gas", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", value_fn=lambda data: get_gas_price(data, 1), ), EnergyZeroSensorEntityDescription( key="current_hour_price", - name="Current hour", + translation_key="current_hour_price", service_type="today_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", @@ -65,7 +65,7 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( ), EnergyZeroSensorEntityDescription( key="next_hour_price", - name="Next hour", + translation_key="next_hour_price", service_type="today_energy", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.price_at_time( @@ -74,42 +74,42 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( ), EnergyZeroSensorEntityDescription( key="average_price", - name="Average - today", + translation_key="average_price", service_type="today_energy", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.average_price, ), EnergyZeroSensorEntityDescription( key="max_price", - name="Highest price - today", + translation_key="max_price", service_type="today_energy", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_prices[1], ), EnergyZeroSensorEntityDescription( key="min_price", - name="Lowest price - today", + translation_key="min_price", service_type="today_energy", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", value_fn=lambda data: data.energy_today.extreme_prices[0], ), EnergyZeroSensorEntityDescription( key="highest_price_time", - name="Time of highest price - today", + translation_key="highest_price_time", service_type="today_energy", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.highest_price_time, ), EnergyZeroSensorEntityDescription( key="lowest_price_time", - name="Time of lowest price - today", + translation_key="lowest_price_time", service_type="today_energy", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.energy_today.lowest_price_time, ), EnergyZeroSensorEntityDescription( key="percentage_of_max", - name="Current percentage of highest price - today", + translation_key="percentage_of_max", service_type="today_energy", native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index ed89e0068d4..93fb264b01d 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -8,5 +8,39 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "current_hour_price": { + "name": "Current hour" + }, + "next_hour_price": { + "name": "Next hour" + }, + "average_price": { + "name": "Average - today" + }, + "max_price": { + "name": "Highest price - today" + }, + "min_price": { + "name": "Lowest price - today" + }, + "highest_price_time": { + "name": "Time of highest price - today" + }, + "lowest_price_time": { + "name": "Time of lowest price - today" + }, + "percentage_of_max": { + "name": "Current percentage of highest price - today" + }, + "hours_priced_equal_or_lower": { + "name": "Hours priced equal or lower than current - today" + }, + "hours_priced_equal_or_higher": { + "name": "Hours priced equal or higher than current - today" + } + } } } diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 447c9034309..28a8d0ba28a 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": ["envoy_reader"], - "requirements": ["envoy_reader==0.20.1"], + "requirements": ["envoy-reader==0.20.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 2870a61d9a0..44ffbcdb497 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -168,7 +169,7 @@ class EnvoyInverter(CoordinatorEntity, SensorEntity): """Initialize Envoy inverter entity.""" self.entity_description = description self._serial_number = serial_number - if description.name: + if description.name is not UNDEFINED: self._attr_name = ( f"{envoy_name} Inverter {serial_number} {description.name}" ) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 7b93f0b28f4..385f973a25a 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -42,7 +42,7 @@ class ECCamera(CoordinatorEntity, Camera): """Implementation of an Environment Canada radar camera.""" _attr_has_entity_name = True - _attr_name = "Radar" + _attr_translation_key = "radar" def __init__(self, coordinator): """Initialize the camera.""" diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 6262a28302f..4a8a9dec587 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env_canada==0.5.34"] + "requirements": ["env-canada==0.5.35"] } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index e7eceb8dadc..987a779d2e8 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -52,12 +52,12 @@ class ECSensorEntityDescription( SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ECSensorEntityDescription( key="condition", - name="Current condition", + translation_key="condition", value_fn=lambda data: data.conditions.get("condition", {}).get("value"), ), ECSensorEntityDescription( key="dewpoint", - name="Dew point", + translation_key="dewpoint", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -65,7 +65,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="high_temp", - name="High temperature", + translation_key="high_temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -73,7 +73,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="humidex", - name="Humidex", + translation_key="humidex", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -81,7 +81,6 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -89,11 +88,13 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="icon_code", + translation_key="icon_code", name="Icon code", value_fn=lambda data: data.conditions.get("icon_code", {}).get("value"), ), ECSensorEntityDescription( key="low_temp", + translation_key="low_temp", name="Low temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -102,27 +103,27 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="normal_high", - name="Normal high temperature", + translation_key="normal_high", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: data.conditions.get("normal_high", {}).get("value"), ), ECSensorEntityDescription( key="normal_low", - name="Normal low temperature", + translation_key="normal_low", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: data.conditions.get("normal_low", {}).get("value"), ), ECSensorEntityDescription( key="pop", - name="Chance of precipitation", + translation_key="pop", native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: data.conditions.get("pop", {}).get("value"), ), ECSensorEntityDescription( key="precip_yesterday", - name="Precipitation yesterday", + translation_key="precip_yesterday", device_class=SensorDeviceClass.PRECIPITATION, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, state_class=SensorStateClass.MEASUREMENT, @@ -130,7 +131,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="pressure", - name="Barometric pressure", + translation_key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.KPA, state_class=SensorStateClass.MEASUREMENT, @@ -138,7 +139,6 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -146,32 +146,32 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="tendency", - name="Tendency", + translation_key="tendency", value_fn=lambda data: data.conditions.get("tendency", {}).get("value"), transform=lambda val: str(val).capitalize(), ), ECSensorEntityDescription( key="text_summary", - name="Summary", + translation_key="text_summary", value_fn=lambda data: data.conditions.get("text_summary", {}).get("value"), transform=lambda val: val[:255], ), ECSensorEntityDescription( key="timestamp", - name="Observation time", + translation_key="timestamp", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.metadata.get("timestamp"), ), ECSensorEntityDescription( key="uv_index", - name="UV index", + translation_key="uv_index", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.conditions.get("uv_index", {}).get("value"), ), ECSensorEntityDescription( key="visibility", - name="Visibility", + translation_key="visibility", native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, @@ -179,13 +179,13 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="wind_bearing", - name="Wind bearing", + translation_key="wind_bearing", native_unit_of_measurement=DEGREE, value_fn=lambda data: data.conditions.get("wind_bearing", {}).get("value"), ), ECSensorEntityDescription( key="wind_chill", - name="Wind chill", + translation_key="wind_chill", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -193,12 +193,12 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="wind_dir", - name="Wind direction", + translation_key="wind_dir", value_fn=lambda data: data.conditions.get("wind_dir", {}).get("value"), ), ECSensorEntityDescription( key="wind_gust", - name="Wind gust", + translation_key="wind_gust", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -206,7 +206,6 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( ), ECSensorEntityDescription( key="wind_speed", - name="Wind speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -226,7 +225,7 @@ def _get_aqhi_value(data): AQHI_SENSOR = ECSensorEntityDescription( key="aqhi", - name="AQHI", + translation_key="aqhi", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, value_fn=_get_aqhi_value, @@ -235,35 +234,35 @@ AQHI_SENSOR = ECSensorEntityDescription( ALERT_TYPES: tuple[ECSensorEntityDescription, ...] = ( ECSensorEntityDescription( key="advisories", - name="Advisory", + translation_key="advisories", icon="mdi:bell-alert", value_fn=lambda data: data.alerts.get("advisories", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="endings", - name="Endings", + translation_key="endings", icon="mdi:alert-circle-check", value_fn=lambda data: data.alerts.get("endings", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="statements", - name="Statements", + translation_key="statements", icon="mdi:bell-alert", value_fn=lambda data: data.alerts.get("statements", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="warnings", - name="Warnings", + translation_key="warnings", icon="mdi:alert-octagon", value_fn=lambda data: data.alerts.get("warnings", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="watches", - name="Watches", + translation_key="watches", icon="mdi:alert", value_fn=lambda data: data.alerts.get("watches", {}).get("value"), transform=len, diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 4c6d75cfeb6..d30124ddf5a 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -22,5 +22,100 @@ "too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "condition": { + "name": "Current condition" + }, + "dewpoint": { + "name": "Dew point" + }, + "high_temp": { + "name": "High temperature" + }, + "humidex": { + "name": "Humidex" + }, + "icon_code": { + "name": "Icon code" + }, + "low_temp": { + "name": "Low temperature" + }, + "normal_high": { + "name": "Normal high temperature" + }, + "normal_low": { + "name": "Normal low temperature" + }, + "pop": { + "name": "Chance of precipitation" + }, + "precip_yesterday": { + "name": "Precipitation yesterday" + }, + "pressure": { + "name": "Barometric pressure" + }, + "tendency": { + "name": "Tendency" + }, + "text_summary": { + "name": "Summary" + }, + "timestamp": { + "name": "Observation time" + }, + "uv_index": { + "name": "UV index" + }, + "visibility": { + "name": "Visibility" + }, + "wind_bearing": { + "name": "Wind bearing" + }, + "wind_chill": { + "name": "Wind chill" + }, + "wind_dir": { + "name": "Wind direction" + }, + "wind_gust": { + "name": "Wind gust" + }, + "aqhi": { + "name": "AQHI" + }, + "advisories": { + "name": "Advisory" + }, + "endings": { + "name": "Endings" + }, + "statements": { + "name": "Statements" + }, + "warnings": { + "name": "Warnings" + }, + "watches": { + "name": "Watches" + } + }, + "camera": { + "radar": { + "name": "Radar" + } + }, + "weather": { + "hourly_forecast": { + "name": "Hourly forecast" + }, + "forecast": { + "name": "Forecast" + } + } } } diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 32ccfa901db..a9f79907b54 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -80,7 +80,7 @@ class ECWeather(CoordinatorEntity, WeatherEntity): super().__init__(coordinator) self.ec_data = coordinator.ec_data self._attr_attribution = self.ec_data.metadata["attribution"] - self._attr_name = "Hourly forecast" if hourly else "Forecast" + self._attr_translation_key = "hourly_forecast" if hourly else "forecast" self._attr_unique_id = ( f"{coordinator.config_entry.unique_id}{'-hourly' if hourly else '-daily'}" ) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index f95763d3a6c..afaefe117ba 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -1,20 +1,14 @@ """Support for esphome devices.""" from __future__ import annotations -from collections.abc import Callable -import functools import logging -import math -from typing import Any, Generic, NamedTuple, TypeVar, cast +from typing import Any, NamedTuple, TypeVar from aioesphomeapi import ( APIClient, APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, - EntityCategory as EsphomeEntityCategory, - EntityInfo, - EntityState, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -36,7 +30,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, EVENT_HOMEASSISTANT_STOP, - EntityCategory, __version__ as ha_version, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback @@ -45,9 +38,6 @@ from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( IssueSeverity, @@ -56,15 +46,19 @@ from homeassistant.helpers.issue_registry import ( ) from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType from .bluetooth import async_connect_scanner -from .const import DOMAIN -from .dashboard import async_get_dashboard +from .const import ( + CONF_ALLOW_SERVICE_CALLS, + DEFAULT_ALLOW_SERVICE_CALLS, + DOMAIN, +) +from .dashboard import async_get_dashboard, async_setup as async_setup_dashboard from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import RuntimeEntryData -from .enum_mapper import EsphomeEnumMapper from .voice_assistant import VoiceAssistantUDPServer CONF_DEVICE_NAME = "device_name" @@ -72,23 +66,25 @@ CONF_NOISE_PSK = "noise_psk" _LOGGER = logging.getLogger(__name__) _R = TypeVar("_R") -STABLE_BLE_VERSION_STR = "2023.4.0" +STABLE_BLE_VERSION_STR = "2023.6.0" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", } DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + @callback def _async_check_firmware_version( - hass: HomeAssistant, device_info: EsphomeDeviceInfo + hass: HomeAssistant, device_info: EsphomeDeviceInfo, api_version: APIVersion ) -> None: """Create or delete an the ble_firmware_outdated issue.""" # ESPHome device_info.mac_address is the unique_id issue = f"ble_firmware_outdated-{device_info.mac_address}" if ( - not device_info.bluetooth_proxy_version + not device_info.bluetooth_proxy_feature_flags_compat(api_version) # If the device has a project name its up to that project # to tell them about the firmware version update so we don't notify here or (device_info.project_name and device_info.project_name not in PROJECT_URLS) @@ -135,6 +131,12 @@ def _async_check_using_api_password( ) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the esphome component.""" + await async_setup_dashboard(hass) + return True + + async def async_setup_entry( # noqa: C901 hass: HomeAssistant, entry: ConfigEntry ) -> bool: @@ -143,7 +145,7 @@ async def async_setup_entry( # noqa: C901 port = entry.data[CONF_PORT] password = entry.data[CONF_PASSWORD] noise_psk = entry.data.get(CONF_NOISE_PSK) - device_id: str | None = None + device_id: str = None # type: ignore[assignment] zeroconf_instance = await zeroconf.async_get_instance(hass) @@ -156,11 +158,16 @@ async def async_setup_entry( # noqa: C901 noise_psk=noise_psk, ) + services_issue = f"service_calls_not_enabled-{entry.unique_id}" + if entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS): + async_delete_issue(hass, DOMAIN, services_issue) + domain_data = DomainData.get(hass) entry_data = RuntimeEntryData( client=cli, entry_id=entry.entry_id, store=domain_data.get_or_create_store(hass, entry), + original_options=dict(entry.options), ) domain_data.set_entry_data(entry, entry_data) @@ -179,6 +186,8 @@ async def async_setup_entry( # noqa: C901 @callback def async_on_service_call(service: HomeassistantServiceCall) -> None: """Call service when user automation in ESPHome config is triggered.""" + device_info = entry_data.device_info + assert device_info is not None domain, service_name = service.service.split(".", 1) service_data = service.data @@ -196,7 +205,7 @@ async def async_setup_entry( # noqa: C901 return if service.is_event: - # ESPHome uses servicecall packet for both events and service calls + # ESPHome uses service call packet for both events and service calls # Ensure the user can only send events of form 'esphome.xyz' if domain != "esphome": _LOGGER.error( @@ -217,12 +226,34 @@ async def async_setup_entry( # noqa: C901 **service_data, }, ) - else: + elif entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS): hass.async_create_task( hass.services.async_call( domain, service_name, service_data, blocking=True ) ) + else: + async_create_issue( + hass, + DOMAIN, + services_issue, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="service_calls_not_allowed", + translation_placeholders={ + "name": device_info.friendly_name or device_info.name, + }, + ) + _LOGGER.error( + "%s: Service call %s.%s: with data %s rejected; " + "If you trust this device and want to allow access for it to make " + "Home Assistant service calls, you can enable this " + "functionality in the options flow", + device_info.friendly_name or device_info.name, + domain, + service_name, + service_data, + ) async def _send_home_assistant_state( entity_id: str, attribute: str | None, state: State | None @@ -316,6 +347,7 @@ async def async_setup_entry( # noqa: C901 hass.async_create_background_task( voice_assistant_udp_server.run_pipeline( + device_id=device_id, conversation_id=conversation_id or None, use_vad=use_vad, ), @@ -359,7 +391,7 @@ async def async_setup_entry( # noqa: C901 if entry_data.device_info.name: reconnect_logic.name = entry_data.device_info.name - if device_info.bluetooth_proxy_version: + if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): entry_data.disconnect_callbacks.append( await async_connect_scanner(hass, entry, cli, entry_data) ) @@ -390,17 +422,23 @@ async def async_setup_entry( # noqa: C901 # Re-connection logic will trigger after this await cli.disconnect() else: - _async_check_firmware_version(hass, device_info) + _async_check_firmware_version(hass, device_info, entry_data.api_version) _async_check_using_api_password(hass, device_info, bool(password)) - async def on_disconnect() -> None: + async def on_disconnect(expected_disconnect: bool) -> None: """Run disconnect callbacks on API disconnect.""" name = entry_data.device_info.name if entry_data.device_info else host - _LOGGER.debug("%s: %s disconnected, running disconnected callbacks", name, host) + _LOGGER.debug( + "%s: %s disconnected (expected=%s), running disconnected callbacks", + name, + host, + expected_disconnect, + ) for disconnect_cb in entry_data.disconnect_callbacks: disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False + entry_data.expected_disconnect = expected_disconnect # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects entry_data.stale_state = { @@ -450,6 +488,8 @@ async def async_setup_entry( # noqa: C901 await reconnect_logic.start() entry_data.cleanup_callbacks.append(reconnect_logic.stop_callback) + entry.async_on_unload(entry.add_update_listener(entry_data.async_update_listener)) + return True @@ -641,6 +681,7 @@ async def _cleanup_instance( data.disconnect_callbacks = [] for cleanup_callback in data.cleanup_callbacks: cleanup_callback() + await data.async_cleanup() await data.client.disconnect() return data @@ -656,291 +697,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove an esphome config entry.""" await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() - - -_InfoT = TypeVar("_InfoT", bound=EntityInfo) -_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") -_StateT = TypeVar("_StateT", bound=EntityState) - - -async def platform_async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - *, - component_key: str, - info_type: type[_InfoT], - entity_type: type[_EntityT], - state_type: type[_StateT], -) -> None: - """Set up an esphome platform. - - This method is in charge of receiving, distributing and storing - info and state updates. - """ - entry_data: RuntimeEntryData = DomainData.get(hass).get_entry_data(entry) - entry_data.info[component_key] = {} - entry_data.old_info[component_key] = {} - entry_data.state.setdefault(state_type, {}) - - @callback - def async_list_entities(infos: list[EntityInfo]) -> None: - """Update entities of this platform when entities are listed.""" - old_infos = entry_data.info[component_key] - new_infos: dict[int, EntityInfo] = {} - add_entities: list[_EntityT] = [] - for info in infos: - if not isinstance(info, info_type): - # Filter out infos that don't belong to this platform. - continue - - if info.key in old_infos: - # Update existing entity - old_infos.pop(info.key) - else: - # Create new entity - entity = entity_type(entry_data, component_key, info.key, state_type) - add_entities.append(entity) - new_infos[info.key] = info - - # Remove old entities - for info in old_infos.values(): - entry_data.async_remove_entity(hass, component_key, info.key) - - # First copy the now-old info into the backup object - entry_data.old_info[component_key] = entry_data.info[component_key] - # Then update the actual info - entry_data.info[component_key] = new_infos - - # Add entities to Home Assistant - async_add_entities(add_entities) - - entry_data.cleanup_callbacks.append( - async_dispatcher_connect( - hass, entry_data.signal_static_info_updated, async_list_entities - ) - ) - - -def esphome_state_property( - func: Callable[[_EntityT], _R] -) -> Callable[[_EntityT], _R | None]: - """Wrap a state property of an esphome entity. - - This checks if the state object in the entity is set, and - prevents writing NAN values to the Home Assistant state machine. - """ - - @functools.wraps(func) - def _wrapper(self: _EntityT) -> _R | None: - # pylint: disable-next=protected-access - if not self._has_state: - return None - val = func(self) - if isinstance(val, float) and math.isnan(val): - # Home Assistant doesn't use NAN values in state machine - # (not JSON serializable) - return None - return val - - return _wrapper - - -ICON_SCHEMA = vol.Schema(cv.icon) - - -ENTITY_CATEGORIES: EsphomeEnumMapper[ - EsphomeEntityCategory, EntityCategory | None -] = EsphomeEnumMapper( - { - EsphomeEntityCategory.NONE: None, - EsphomeEntityCategory.CONFIG: EntityCategory.CONFIG, - EsphomeEntityCategory.DIAGNOSTIC: EntityCategory.DIAGNOSTIC, - } -) - - -class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): - """Define a base esphome entity.""" - - _attr_should_poll = False - - def __init__( - self, - entry_data: RuntimeEntryData, - component_key: str, - key: int, - state_type: type[_StateT], - ) -> None: - """Initialize.""" - self._entry_data = entry_data - self._component_key = component_key - self._key = key - self._state_type = state_type - if entry_data.device_info is not None and entry_data.device_info.friendly_name: - self._attr_has_entity_name = True - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"esphome_{self._entry_id}_remove_{self._component_key}_{self._key}", - functools.partial(self.async_remove, force_remove=True), - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - self._entry_data.signal_device_updated, - self._on_device_update, - ) - ) - - self.async_on_remove( - self._entry_data.async_subscribe_state_update( - self._state_type, self._key, self._on_state_update - ) - ) - - @callback - def _on_state_update(self) -> None: - # Behavior can be changed in child classes - self.async_write_ha_state() - - @callback - def _on_device_update(self) -> None: - """Update the entity state when device info has changed.""" - if self._entry_data.available: - # Don't update the HA state yet when the device comes online. - # Only update the HA state when the full state arrives - # through the next entity state packet. - return - self._on_state_update() - - @property - def _entry_id(self) -> str: - return self._entry_data.entry_id - - @property - def _api_version(self) -> APIVersion: - return self._entry_data.api_version - - @property - def _static_info(self) -> _InfoT: - # Check if value is in info database. Use a single lookup. - info = self._entry_data.info[self._component_key].get(self._key) - if info is not None: - return cast(_InfoT, info) - # This entity is in the removal project and has been removed from .info - # already, look in old_info - return cast(_InfoT, self._entry_data.old_info[self._component_key][self._key]) - - @property - def _device_info(self) -> EsphomeDeviceInfo: - assert self._entry_data.device_info is not None - return self._entry_data.device_info - - @property - def _client(self) -> APIClient: - return self._entry_data.client - - @property - def _state(self) -> _StateT: - return cast(_StateT, self._entry_data.state[self._state_type][self._key]) - - @property - def _has_state(self) -> bool: - return self._key in self._entry_data.state[self._state_type] - - @property - def available(self) -> bool: - """Return if the entity is available.""" - device = self._device_info - - if device.has_deep_sleep: - # During deep sleep the ESP will not be connectable (by design) - # For these cases, show it as available - return True - - return self._entry_data.available - - @property - def unique_id(self) -> str | None: - """Return a unique id identifying the entity.""" - if not self._static_info.unique_id: - return None - return self._static_info.unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} - ) - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._static_info.name - - @property - def icon(self) -> str | None: - """Return the icon.""" - if not self._static_info.icon: - return None - - return cast(str, ICON_SCHEMA(self._static_info.icon)) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added. - - This only applies when fist added to the entity registry. - """ - return not self._static_info.disabled_by_default - - @property - def entity_category(self) -> EntityCategory | None: - """Return the category of the entity, if any.""" - if not self._static_info.entity_category: - return None - return ENTITY_CATEGORIES.from_esphome(self._static_info.entity_category) - - -class EsphomeAssistEntity(Entity): - """Define a base entity for Assist Pipeline entities.""" - - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__(self, entry_data: RuntimeEntryData) -> None: - """Initialize the binary sensor.""" - self._entry_data: RuntimeEntryData = entry_data - self._attr_unique_id = ( - f"{self._device_info.mac_address}-{self.entity_description.key}" - ) - - @property - def _device_info(self) -> EsphomeDeviceInfo: - assert self._entry_data.device_info is not None - return self._entry_data.device_info - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} - ) - - @callback - def _update(self) -> None: - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register update callback.""" - await super().async_added_to_hass() - self.async_on_remove( - self._entry_data.async_subscribe_assist_pipeline_update(self._update) - ) diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py new file mode 100644 index 00000000000..639f47272d9 --- /dev/null +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -0,0 +1,160 @@ +"""Support for ESPHome Alarm Control Panel.""" +from __future__ import annotations + +from aioesphomeapi import ( + AlarmControlPanelCommand, + AlarmControlPanelEntityState, + AlarmControlPanelInfo, + AlarmControlPanelState, + APIIntEnum, + EntityInfo, +) + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_DISARMING, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, +) +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 + +_ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ + AlarmControlPanelState, str +] = EsphomeEnumMapper( + { + AlarmControlPanelState.DISARMED: STATE_ALARM_DISARMED, + AlarmControlPanelState.ARMED_HOME: STATE_ALARM_ARMED_HOME, + AlarmControlPanelState.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, + AlarmControlPanelState.ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, + AlarmControlPanelState.ARMED_VACATION: STATE_ALARM_ARMED_VACATION, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS: STATE_ALARM_ARMED_CUSTOM_BYPASS, + AlarmControlPanelState.PENDING: STATE_ALARM_PENDING, + AlarmControlPanelState.ARMING: STATE_ALARM_ARMING, + AlarmControlPanelState.DISARMING: STATE_ALARM_DISARMING, + AlarmControlPanelState.TRIGGERED: STATE_ALARM_TRIGGERED, + } +) + + +class EspHomeACPFeatures(APIIntEnum): + """ESPHome AlarmCintolPanel feature numbers.""" + + ARM_HOME = 1 + ARM_AWAY = 2 + ARM_NIGHT = 4 + TRIGGER = 8 + ARM_CUSTOM_BYPASS = 16 + ARM_VACATION = 32 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up ESPHome switches based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + info_type=AlarmControlPanelInfo, + entity_type=EsphomeAlarmControlPanel, + state_type=AlarmControlPanelEntityState, + ) + + +class EsphomeAlarmControlPanel( + EsphomeEntity[AlarmControlPanelInfo, AlarmControlPanelEntityState], + AlarmControlPanelEntity, +): + """An Alarm Control Panel 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 + feature = 0 + if self._static_info.supported_features & EspHomeACPFeatures.ARM_HOME: + feature |= AlarmControlPanelEntityFeature.ARM_HOME + if self._static_info.supported_features & EspHomeACPFeatures.ARM_AWAY: + feature |= AlarmControlPanelEntityFeature.ARM_AWAY + if self._static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT: + feature |= AlarmControlPanelEntityFeature.ARM_NIGHT + if self._static_info.supported_features & EspHomeACPFeatures.TRIGGER: + feature |= AlarmControlPanelEntityFeature.TRIGGER + if self._static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS: + feature |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + if self._static_info.supported_features & EspHomeACPFeatures.ARM_VACATION: + feature |= AlarmControlPanelEntityFeature.ARM_VACATION + self._attr_supported_features = AlarmControlPanelEntityFeature(feature) + self._attr_code_format = ( + CodeFormat.NUMBER if static_info.requires_code else None + ) + self._attr_code_arm_required = bool(static_info.requires_code_to_arm) + + @property + @esphome_state_property + def state(self) -> str | None: + """Return the state of the device.""" + return _ESPHOME_ACP_STATE_TO_HASS_STATE.from_esphome(self._state.state) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + await self._client.alarm_control_panel_command( + self._key, AlarmControlPanelCommand.DISARM, code + ) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self._client.alarm_control_panel_command( + self._key, AlarmControlPanelCommand.ARM_HOME, code + ) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._client.alarm_control_panel_command( + self._key, AlarmControlPanelCommand.ARM_AWAY, code + ) + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._client.alarm_control_panel_command( + self._key, AlarmControlPanelCommand.ARM_NIGHT, code + ) + + async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._client.alarm_control_panel_command( + self._key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code + ) + + async def async_alarm_arm_vacation(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._client.alarm_control_panel_command( + self._key, AlarmControlPanelCommand.ARM_VACATION, code + ) + + async def async_alarm_trigger(self, code: str | None = None) -> None: + """Send alarm trigger command.""" + await self._client.alarm_control_panel_command( + self._key, AlarmControlPanelCommand.TRIGGER, code + ) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 77ec780acb3..65a237de4f7 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -1,7 +1,7 @@ """Support for ESPHome binary sensors.""" from __future__ import annotations -from aioesphomeapi import BinarySensorInfo, BinarySensorState +from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -9,12 +9,16 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) 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.util.enum import try_parse_enum -from . import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry from .domain_data import DomainData +from .entity import ( + EsphomeAssistEntity, + EsphomeEntity, + platform_async_setup_entry, +) async def async_setup_entry( @@ -25,7 +29,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="binary_sensor", info_type=BinarySensorInfo, entity_type=EsphomeBinarySensor, state_type=BinarySensorState, @@ -49,23 +52,22 @@ class EsphomeBinarySensor( # Status binary sensors indicated connected state. # So in their case what's usually _availability_ is now state return self._entry_data.available - if not self._has_state: - return None - if self._state.missing_state: + if not self._has_state or self._state.missing_state: return None return self._state.state - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return try_parse_enum(BinarySensorDeviceClass, self._static_info.device_class) + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + self._attr_device_class = try_parse_enum( + BinarySensorDeviceClass, self._static_info.device_class + ) @property def available(self) -> bool: """Return True if entity is available.""" - if self._static_info.is_status_binary_sensor: - return True - return super().available + return self._static_info.is_status_binary_sensor or super().available class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntity): diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index e62b54655c8..aea65f9358e 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -5,7 +5,7 @@ from collections.abc import Callable from functools import partial import logging -from aioesphomeapi import APIClient +from aioesphomeapi import APIClient, BluetoothProxyFeature from homeassistant.components.bluetooth import ( HaBluetoothConnector, @@ -59,13 +59,15 @@ async def async_connect_scanner( source = str(entry.unique_id) new_info_callback = async_get_advertisement_callback(hass) assert entry_data.device_info is not None - version = entry_data.device_info.bluetooth_proxy_version - connectable = version >= 2 + feature_flags = entry_data.device_info.bluetooth_proxy_feature_flags_compat( + entry_data.api_version + ) + connectable = bool(feature_flags & BluetoothProxyFeature.ACTIVE_CONNECTIONS) _LOGGER.debug( - "%s [%s]: Connecting scanner version=%s, connectable=%s", + "%s [%s]: Connecting scanner feature_flags=%s, connectable=%s", entry.title, source, - version, + feature_flags, connectable, ) connector = HaBluetoothConnector( @@ -89,7 +91,12 @@ async def async_connect_scanner( async_register_scanner(hass, scanner, connectable), scanner.async_setup(), ] - await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) + if feature_flags & BluetoothProxyFeature.RAW_ADVERTISEMENTS: + await cli.subscribe_bluetooth_le_raw_advertisements( + scanner.async_on_raw_advertisements + ) + else: + await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) @hass_callback def _async_unload() -> None: diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 914021b467e..d452ab8764a 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( ESP_CONNECTION_ERROR_DESCRIPTION, ESPHOME_GATT_ERRORS, BLEConnectionError, + BluetoothProxyFeature, ) from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError from aioesphomeapi.core import BluetoothGATTAPIError @@ -42,10 +43,6 @@ CCCD_UUID = "00002902-0000-1000-8000-00805f9b34fb" CCCD_NOTIFY_BYTES = b"\x01\x00" CCCD_INDICATE_BYTES = b"\x02\x00" -MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE = 3 -MIN_BLUETOOTH_PROXY_HAS_PAIRING = 4 -MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE = 5 - DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -158,7 +155,10 @@ class ESPHomeClient(BaseBleakClient): self._disconnected_event: asyncio.Event | None = None device_info = self.entry_data.device_info assert device_info is not None - self._connection_version = device_info.bluetooth_proxy_version + self._device_info = device_info + self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( + self.entry_data.api_version + ) self._address_type = address_or_ble_device.details["address_type"] self._source_name = f"{config_entry.title} [{self._source}]" @@ -233,7 +233,7 @@ class ESPHomeClient(BaseBleakClient): ) -> bool: """Connect to a specified Peripheral. - Keyword Args: + **kwargs: timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0. @@ -247,7 +247,7 @@ class ESPHomeClient(BaseBleakClient): self._mtu = domain_data.get_gatt_mtu_cache(self._address_as_int) has_cache = bool( dangerous_use_bleak_cache - and self._connection_version >= MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE + and self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING and domain_data.get_gatt_services_cache(self._address_as_int) and self._mtu ) @@ -319,7 +319,7 @@ class ESPHomeClient(BaseBleakClient): _on_bluetooth_connection_state, timeout=timeout, has_cache=has_cache, - version=self._connection_version, + feature_flags=self._feature_flags, address_type=self._address_type, ) ) @@ -397,9 +397,10 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def pair(self, *args: Any, **kwargs: Any) -> bool: """Attempt to pair.""" - if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + if not self._feature_flags & BluetoothProxyFeature.PAIRING: raise NotImplementedError( - "Pairing is not available in ESPHome with version {self._connection_version}." + "Pairing is not available in this version ESPHome; " + f"Upgrade the ESPHome version on the {self._device_info.name} device." ) response = await self._client.bluetooth_device_pair(self._address_as_int) if response.paired: @@ -413,9 +414,10 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def unpair(self) -> bool: """Attempt to unpair.""" - if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + if not self._feature_flags & BluetoothProxyFeature.PAIRING: raise NotImplementedError( - "Unpairing is not available in ESPHome with version {self._connection_version}." + "Unpairing is not available in this version ESPHome; " + f"Upgrade the ESPHome version on the {self._device_info.name} device." ) response = await self._client.bluetooth_device_unpair(self._address_as_int) if response.success: @@ -441,7 +443,7 @@ class ESPHomeClient(BaseBleakClient): # because the esp has already wiped the services list to # save memory. if ( - self._connection_version >= MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE + self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING or dangerous_use_bleak_cache ) and (cached_services := domain_data.get_gatt_services_cache(address_as_int)): _LOGGER.debug( @@ -524,12 +526,11 @@ class ESPHomeClient(BaseBleakClient): """Clear the GATT cache.""" self.domain_data.clear_gatt_services_cache(self._address_as_int) self.domain_data.clear_gatt_mtu_cache(self._address_as_int) - if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE: + if not self._feature_flags & BluetoothProxyFeature.CACHE_CLEARING: _LOGGER.warning( - "On device cache clear is not available with ESPHome Bluetooth version %s, " - "version %s is needed; Only memory cache will be cleared", - self._connection_version, - MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE, + "On device cache clear is not available with this ESPHome version; " + "Upgrade the ESPHome version on the device %s; Only memory cache will be cleared", + self._device_info.name, ) return True response = await self._client.bluetooth_device_clear_cache(self._address_as_int) @@ -673,7 +674,7 @@ class ESPHomeClient(BaseBleakClient): lambda handle, data: callback(data), ) - if self._connection_version < MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE: + if not self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING: return # For connection v3 we are responsible for enabling notifications diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index 6151ed30429..5013a288dcf 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -1,10 +1,10 @@ """Bluetooth scanner for esphome.""" from __future__ import annotations -from aioesphomeapi import BluetoothLEAdvertisement -from bluetooth_data_tools import int_to_bluetooth_address +from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisement +from bluetooth_data_tools import int_to_bluetooth_address, parse_advertisement_data -from homeassistant.components.bluetooth import BaseHaRemoteScanner +from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner from homeassistant.core import callback @@ -24,4 +24,25 @@ class ESPHomeScanner(BaseHaRemoteScanner): adv.manufacturer_data, None, {"address_type": adv.address_type}, + MONOTONIC_TIME(), ) + + @callback + def async_on_raw_advertisements( + self, advertisements: list[BluetoothLERawAdvertisement] + ) -> None: + """Call the registered callback.""" + now = MONOTONIC_TIME() + for adv in advertisements: + parsed = parse_advertisement_data((adv.data,)) + self._async_on_advertisement( + int_to_bluetooth_address(adv.address), + adv.rssi, + parsed.local_name, + parsed.service_uuids, + parsed.service_data, + parsed.manufacturer_data, + None, + {"address_type": adv.address_type}, + now, + ) diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 0cb577f30c9..eca8d226c69 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -1,7 +1,7 @@ """Support for ESPHome buttons.""" from __future__ import annotations -from aioesphomeapi import ButtonInfo, EntityState +from aioesphomeapi import ButtonInfo, EntityInfo, EntityState from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry @@ -9,7 +9,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + platform_async_setup_entry, +) async def async_setup_entry( @@ -20,7 +23,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="button", info_type=ButtonInfo, entity_type=EsphomeButton, state_type=EntityState, @@ -30,18 +32,29 @@ async def async_setup_entry( class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): """A button implementation for ESPHome.""" - @property - def device_class(self) -> ButtonDeviceClass | None: - """Return the class of this entity.""" - return try_parse_enum(ButtonDeviceClass, self._static_info.device_class) + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + self._attr_device_class = try_parse_enum( + ButtonDeviceClass, self._static_info.device_class + ) @callback def _on_device_update(self) -> None: - """Update the entity state when device info has changed.""" - # This override the EsphomeEntity method as the button entity - # never gets a state update. - self._on_state_update() + """Call when device updates or entry data changes. + + The default behavior is only to write entity state when the + device is unavailable when the device state changes. + This method overrides the default behavior since buttons do + not have a state, so we will never get a state update for a + button. As such, we need to write the state on every device + update to ensure the button goes available and unavailable + as the device becomes available or unavailable. + """ + self._on_entry_data_changed() + self.async_write_ha_state() async def async_press(self) -> None: """Press the button.""" - await self._client.button_command(self._static_info.key) + await self._client.button_command(self._key) diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 390208f689d..94a9b03b90c 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -13,7 +13,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + platform_async_setup_entry, +) async def async_setup_entry( @@ -24,7 +27,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="camera", info_type=CameraInfo, entity_type=EsphomeCamera, state_type=CameraState, diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index e40df234d58..34043da012e 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -11,6 +11,7 @@ from aioesphomeapi import ( ClimatePreset, ClimateState, ClimateSwingMode, + EntityInfo, ) from homeassistant.components.climate import ( @@ -51,10 +52,14 @@ from homeassistant.const import ( PRECISION_WHOLE, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper FAN_QUIET = "quiet" @@ -68,7 +73,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="climate", info_type=ClimateInfo, entity_type=EsphomeClimateEntity, state_type=ClimateState, @@ -137,71 +141,32 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti _attr_temperature_unit = UnitOfTemperature.CELSIUS - @property - def precision(self) -> float: - """Return the precision of the climate device.""" - precicions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] - if self._static_info.visual_current_temperature_step != 0: - step = self._static_info.visual_current_temperature_step - else: - step = self._static_info.visual_target_temperature_step - for prec in precicions: - if step >= prec: - return prec - # Fall back to highest precision, tenths - return PRECISION_TENTHS - - @property - def hvac_modes(self) -> list[str]: - """Return the list of available operation modes.""" - return [ - _CLIMATE_MODES.from_esphome(mode) - for mode in self._static_info.supported_modes + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_precision = self._get_precision() + self._attr_hvac_modes = [ + _CLIMATE_MODES.from_esphome(mode) for mode in static_info.supported_modes ] - - @property - def fan_modes(self) -> list[str]: - """Return the list of available fan modes.""" - return [ - _FAN_MODES.from_esphome(mode) - for mode in self._static_info.supported_fan_modes - ] + self._static_info.supported_custom_fan_modes - - @property - def preset_modes(self) -> list[str]: - """Return preset modes.""" - return [ + self._attr_fan_modes = [ + _FAN_MODES.from_esphome(mode) for mode in static_info.supported_fan_modes + ] + static_info.supported_custom_fan_modes + self._attr_preset_modes = [ _PRESETS.from_esphome(preset) - for preset in self._static_info.supported_presets_compat(self._api_version) - ] + self._static_info.supported_custom_presets - - @property - def swing_modes(self) -> list[str]: - """Return the list of available swing modes.""" - return [ + for preset in static_info.supported_presets_compat(self._api_version) + ] + static_info.supported_custom_presets + self._attr_swing_modes = [ _SWING_MODES.from_esphome(mode) - for mode in self._static_info.supported_swing_modes + for mode in static_info.supported_swing_modes ] - - @property - def target_temperature_step(self) -> float: - """Return the supported step of target temperature.""" # Round to one digit because of floating point math - return round(self._static_info.visual_target_temperature_step, 1) - - @property - def min_temp(self) -> float: - """Return the minimum temperature.""" - return self._static_info.visual_min_temperature - - @property - def max_temp(self) -> float: - """Return the maximum temperature.""" - return self._static_info.visual_max_temperature - - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" + self._attr_target_temperature_step = round( + static_info.visual_target_temperature_step, 1 + ) + self._attr_min_temp = static_info.visual_min_temperature + self._attr_max_temp = static_info.visual_max_temperature features = ClimateEntityFeature(0) if self._static_info.supports_two_point_target_temperature: features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -213,17 +178,31 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti features |= ClimateEntityFeature.FAN_MODE if self.swing_modes: features |= ClimateEntityFeature.SWING_MODE - return features + self._attr_supported_features = features + + def _get_precision(self) -> float: + """Return the precision of the climate device.""" + precicions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + static_info = self._static_info + if static_info.visual_current_temperature_step != 0: + step = static_info.visual_current_temperature_step + else: + step = static_info.visual_target_temperature_step + for prec in precicions: + if step >= prec: + return prec + # Fall back to highest precision, tenths + return PRECISION_TENTHS @property @esphome_state_property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" return _CLIMATE_MODES.from_esphome(self._state.mode) @property @esphome_state_property - def hvac_action(self) -> str | None: + def hvac_action(self) -> HVACAction | None: """Return current action.""" # HA has no support feature field for hvac_action if not self._static_info.supports_action: @@ -234,16 +213,16 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti @esphome_state_property def fan_mode(self) -> str | None: """Return current fan setting.""" - return self._state.custom_fan_mode or _FAN_MODES.from_esphome( - self._state.fan_mode - ) + state = self._state + return state.custom_fan_mode or _FAN_MODES.from_esphome(state.fan_mode) @property @esphome_state_property def preset_mode(self) -> str | None: """Return current preset mode.""" - return self._state.custom_preset or _PRESETS.from_esphome( - self._state.preset_compat(self._api_version) + state = self._state + return state.custom_preset or _PRESETS.from_esphome( + state.preset_compat(self._api_version) ) @property @@ -278,7 +257,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature (and operation mode if set).""" - data: dict[str, Any] = {"key": self._static_info.key} + data: dict[str, Any] = {"key": self._key} if ATTR_HVAC_MODE in kwargs: data["mode"] = _CLIMATE_MODES.from_hass( cast(HVACMode, kwargs[ATTR_HVAC_MODE]) @@ -294,12 +273,12 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" await self._client.climate_command( - key=self._static_info.key, mode=_CLIMATE_MODES.from_hass(hvac_mode) + key=self._key, mode=_CLIMATE_MODES.from_hass(hvac_mode) ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - kwargs: dict[str, Any] = {"key": self._static_info.key} + kwargs: dict[str, Any] = {"key": self._key} if preset_mode in self._static_info.supported_custom_presets: kwargs["custom_preset"] = preset_mode else: @@ -308,7 +287,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" - kwargs: dict[str, Any] = {"key": self._static_info.key} + kwargs: dict[str, Any] = {"key": self._key} if fan_mode in self._static_info.supported_custom_fan_modes: kwargs["custom_fan_mode"] = fan_mode else: @@ -318,5 +297,5 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" await self._client.climate_command( - key=self._static_info.key, swing_mode=_SWING_MODES.from_hass(swing_mode) + key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode) ) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index acc94bc7ea0..731743e48c8 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Mapping +import json import logging from typing import Any @@ -20,14 +21,19 @@ import voluptuous as vol from homeassistant.components import dhcp, zeroconf from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac from . import CONF_DEVICE_NAME, CONF_NOISE_PSK -from .const import DOMAIN +from .const import ( + CONF_ALLOW_SERVICE_CALLS, + DEFAULT_ALLOW_SERVICE_CALLS, + DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + DOMAIN, +) from .dashboard import async_get_dashboard, async_set_dashboard_info ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" @@ -35,6 +41,8 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" ESPHOME_URL = "https://esphome.io/" _LOGGER = logging.getLogger(__name__) +ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" + class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a esphome config flow.""" @@ -144,11 +152,22 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_try_fetch_device_info(self) -> FlowResult: error = await self.fetch_device_info() - if ( - error == ERROR_REQUIRES_ENCRYPTION_KEY - and await self._retrieve_encryption_key_from_dashboard() - ): - error = await self.fetch_device_info() + if error == ERROR_REQUIRES_ENCRYPTION_KEY: + if not self._device_name and not self._noise_psk: + # If device name is not set we can send a zero noise psk + # to get the device name which will allow us to populate + # the device name and hopefully get the encryption key + # from the dashboard. + self._noise_psk = ZERO_NOISE_PSK + error = await self.fetch_device_info() + self._noise_psk = None + + if ( + self._device_name + and await self._retrieve_encryption_key_from_dashboard() + ): + error = await self.fetch_device_info() + # If the fetched key is invalid, unset it again. if error == ERROR_INVALID_ENCRYPTION_KEY: self._noise_psk = None @@ -237,6 +256,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): CONF_NOISE_PSK: self._noise_psk or "", CONF_DEVICE_NAME: self._device_name, } + config_options = { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + } if self._reauth_entry: entry = self._reauth_entry self.hass.config_entries.async_update_entry( @@ -253,6 +275,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=self._name, data=config_data, + options=config_options, ) async def async_step_encryption_key( @@ -314,7 +337,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._device_info = await cli.device_info() except RequiresEncryptionAPIError: return ERROR_REQUIRES_ENCRYPTION_KEY - except InvalidEncryptionKeyAPIError: + except InvalidEncryptionKeyAPIError as ex: + if ex.received_name: + self._device_name = ex.received_name + self._name = ex.received_name return ERROR_INVALID_ENCRYPTION_KEY except ResolveAPIError: return "resolve_error" @@ -325,9 +351,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._name = self._device_info.friendly_name or self._device_info.name self._device_name = self._device_info.name - await self.async_set_unique_id( - self._device_info.mac_address, raise_on_progress=False - ) + mac_address = format_mac(self._device_info.mac_address) + await self.async_set_unique_id(mac_address, raise_on_progress=False) if not self._reauth_entry: self._abort_if_unique_id_configured( updates={CONF_HOST: self._host, CONF_PORT: self._port} @@ -364,14 +389,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): Return boolean if a key was retrieved. """ - if self._device_name is None: - return False - - if (dashboard := async_get_dashboard(self.hass)) is None: + if ( + self._device_name is None + or (dashboard := async_get_dashboard(self.hass)) is None + ): return False await dashboard.async_request_refresh() - if not dashboard.last_update_success: return False @@ -385,6 +409,46 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): except aiohttp.ClientError as err: _LOGGER.error("Error talking to the dashboard: %s", err) return False + except json.JSONDecodeError as err: + _LOGGER.error( + "Error parsing response from dashboard: %s", err, exc_info=True + ) + return False self._noise_psk = noise_psk return True + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(OptionsFlow): + """Handle a option flow for esphome.""" + + def __init__(self, config_entry: 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_SERVICE_CALLS, + default=self.config_entry.options.get( + CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS + ), + ): bool, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 617c817924b..a53bb2db8ed 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,3 +1,7 @@ """ESPHome constants.""" DOMAIN = "esphome" + +CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" +DEFAULT_ALLOW_SERVICE_CALLS = True +DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 9d82b285291..45ef8a132f9 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import APIVersion, CoverInfo, CoverOperation, CoverState +from aioesphomeapi import APIVersion, CoverInfo, CoverOperation, CoverState, EntityInfo from homeassistant.components.cover import ( ATTR_POSITION, @@ -13,11 +13,15 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) 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.util.enum import try_parse_enum -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( @@ -28,7 +32,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="cover", info_type=CoverInfo, entity_type=EsphomeCover, state_type=CoverState, @@ -38,32 +41,27 @@ async def async_setup_entry( class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): """A cover implementation for ESPHome.""" - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" + @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 flags = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - - if self._api_version < APIVersion(1, 8) or self._static_info.supports_stop: + if self._api_version < APIVersion(1, 8) or static_info.supports_stop: flags |= CoverEntityFeature.STOP - if self._static_info.supports_position: + if static_info.supports_position: flags |= CoverEntityFeature.SET_POSITION - if self._static_info.supports_tilt: + if static_info.supports_tilt: flags |= ( CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - return flags - - @property - def device_class(self) -> CoverDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return try_parse_enum(CoverDeviceClass, self._static_info.device_class) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._static_info.assumed_state + self._attr_supported_features = flags + self._attr_device_class = try_parse_enum( + CoverDeviceClass, static_info.device_class + ) + self._attr_assumed_state = static_info.assumed_state @property @esphome_state_property @@ -102,33 +100,31 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._client.cover_command(key=self._static_info.key, position=1.0) + await self._client.cover_command(key=self._key, position=1.0) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._client.cover_command(key=self._static_info.key, position=0.0) + await self._client.cover_command(key=self._key, position=0.0) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._client.cover_command(key=self._static_info.key, stop=True) + await self._client.cover_command(key=self._key, stop=True) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self._client.cover_command( - key=self._static_info.key, position=kwargs[ATTR_POSITION] / 100 + key=self._key, position=kwargs[ATTR_POSITION] / 100 ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - await self._client.cover_command(key=self._static_info.key, tilt=1.0) + await self._client.cover_command(key=self._key, tilt=1.0) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - await self._client.cover_command(key=self._static_info.key, tilt=0.0) + await self._client.cover_command(key=self._key, tilt=0.0) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt_position: int = kwargs[ATTR_TILT_POSITION] - await self._client.cover_command( - key=self._static_info.key, tilt=tilt_position / 100 - ) + await self._client.cover_command(key=self._key, tilt=tilt_position / 100) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index a8332f8d040..35e9cf74555 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging +from typing import Any import aiohttp from awesomeversion import AwesomeVersion @@ -11,62 +12,148 @@ from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -KEY_DASHBOARD = "esphome_dashboard" +_LOGGER = logging.getLogger(__name__) + + +KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" + +STORAGE_KEY = "esphome.dashboard" +STORAGE_VERSION = 1 + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the ESPHome dashboard.""" + # Try to restore the dashboard manager from storage + # to avoid reloading every ESPHome config entry after + # Home Assistant starts and the dashboard is discovered. + await async_get_or_create_dashboard_manager(hass) + + +@singleton(KEY_DASHBOARD_MANAGER) +async def async_get_or_create_dashboard_manager( + hass: HomeAssistant, +) -> ESPHomeDashboardManager: + """Get the dashboard manager or create it.""" + manager = ESPHomeDashboardManager(hass) + await manager.async_setup() + return manager + + +class ESPHomeDashboardManager: + """Class to manage the dashboard and restore it from storage.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the dashboard manager.""" + self._hass = hass + self._store: Store[dict[str, Any]] = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._data: dict[str, Any] | None = None + self._current_dashboard: ESPHomeDashboard | None = None + self._cancel_shutdown: CALLBACK_TYPE | None = None + + async def async_setup(self) -> None: + """Restore the dashboard from storage.""" + self._data = await self._store.async_load() + if (data := self._data) and (info := data.get("info")): + await self.async_set_dashboard_info( + info["addon_slug"], info["host"], info["port"] + ) + + @callback + def async_get(self) -> ESPHomeDashboard | None: + """Get the current dashboard.""" + return self._current_dashboard + + async def async_set_dashboard_info( + self, addon_slug: str, host: str, port: int + ) -> None: + """Set the dashboard info.""" + url = f"http://{host}:{port}" + hass = self._hass + + if cur_dashboard := self._current_dashboard: + if cur_dashboard.addon_slug == addon_slug and cur_dashboard.url == url: + # Do nothing if we already have this data. + return + # Clear and make way for new dashboard + await cur_dashboard.async_shutdown() + if self._cancel_shutdown is not None: + self._cancel_shutdown() + self._cancel_shutdown = None + self._current_dashboard = None + + dashboard = ESPHomeDashboard( + hass, addon_slug, url, async_get_clientsession(hass) + ) + await dashboard.async_request_refresh() + if not cur_dashboard and not dashboard.last_update_success: + # If there was no previous dashboard and the new one is not available, + # we skip setup and wait for discovery. + _LOGGER.error( + "Dashboard unavailable; skipping setup: %s", dashboard.last_exception + ) + return + + self._current_dashboard = dashboard + + async def on_hass_stop(_: Event) -> None: + await dashboard.async_shutdown() + + self._cancel_shutdown = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, on_hass_stop + ) + + new_data = {"info": {"addon_slug": addon_slug, "host": host, "port": port}} + if self._data != new_data: + await self._store.async_save(new_data) + + reloads = [ + hass.config_entries.async_reload(entry.entry_id) + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + # Re-auth flows will check the dashboard for encryption key when the form is requested + # but we only trigger reauth if the dashboard is available. + if dashboard.last_update_success: + reauths = [ + hass.config_entries.flow.async_configure(flow["flow_id"]) + for flow in hass.config_entries.flow.async_progress() + if flow["handler"] == DOMAIN + and flow["context"]["source"] == SOURCE_REAUTH + ] + else: + reauths = [] + _LOGGER.error( + "Dashboard unavailable; skipping reauth: %s", dashboard.last_exception + ) + + _LOGGER.debug( + "Reloading %d and re-authenticating %d", len(reloads), len(reauths) + ) + if reloads or reauths: + await asyncio.gather(*reloads, *reauths) @callback def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None: """Get an instance of the dashboard if set.""" - return hass.data.get(KEY_DASHBOARD) + manager: ESPHomeDashboardManager | None = hass.data.get(KEY_DASHBOARD_MANAGER) + return manager.async_get() if manager else None async def async_set_dashboard_info( hass: HomeAssistant, addon_slug: str, host: str, port: int ) -> None: """Set the dashboard info.""" - url = f"http://{host}:{port}" - - if cur_dashboard := async_get_dashboard(hass): - if cur_dashboard.addon_slug == addon_slug and cur_dashboard.url == url: - # Do nothing if we already have this data. - return - # Clear and make way for new dashboard - await cur_dashboard.async_shutdown() - del hass.data[KEY_DASHBOARD] - - dashboard = ESPHomeDashboard(hass, addon_slug, url, async_get_clientsession(hass)) - try: - await dashboard.async_request_refresh() - except UpdateFailed as err: - logging.getLogger(__name__).error("Ignoring dashboard info: %s", err) - return - - hass.data[KEY_DASHBOARD] = dashboard - - async def on_hass_stop(_: Event) -> None: - await dashboard.async_shutdown() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) - - reloads = [ - hass.config_entries.async_reload(entry.entry_id) - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - # Re-auth flows will check the dashboard for encryption key when the form is requested - reauths = [ - hass.config_entries.flow.async_configure(flow["flow_id"]) - for flow in hass.config_entries.flow.async_progress() - if flow["handler"] == DOMAIN and flow["context"]["source"] == SOURCE_REAUTH - ] - if reloads or reauths: - await asyncio.gather(*reloads, *reauths) + manager = await async_get_or_create_dashboard_manager(hass) + await manager.async_set_dashboard_info(addon_slug, host, port) class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): @@ -82,7 +169,7 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): """Initialize.""" super().__init__( hass, - logging.getLogger(__name__), + _LOGGER, name="ESPHome Dashboard", update_interval=timedelta(minutes=5), ) diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 8de1501bc43..292d1921abf 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -1,7 +1,7 @@ """Diagnostics support for ESPHome.""" from __future__ import annotations -from typing import Any, cast +from typing import Any from homeassistant.components.bluetooth import async_scanner_by_source from homeassistant.components.diagnostics import async_redact_data @@ -28,7 +28,6 @@ async def async_get_config_entry_diagnostics( entry_data = DomainData.get(hass).get_entry_data(config_entry) if (storage_data := await entry_data.store.async_load()) is not None: - storage_data = cast("dict[str, Any]", storage_data) diag["storage_data"] = storage_data if config_entry.unique_id and ( diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index 32d2d1effff..2fc32129d1f 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -12,10 +12,9 @@ from typing_extensions import Self from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.storage import Store from .const import DOMAIN -from .entry_data import RuntimeEntryData +from .entry_data import ESPHomeStorage, RuntimeEntryData STORAGE_VERSION = 1 MAX_CACHED_SERVICES = 128 @@ -26,12 +25,12 @@ class DomainData: """Define a class that stores global esphome data in hass.data[DOMAIN].""" _entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict) - _stores: dict[str, Store] = field(default_factory=dict) + _stores: dict[str, ESPHomeStorage] = field(default_factory=dict) _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return] + default_factory=lambda: LRU(MAX_CACHED_SERVICES) ) _gatt_mtu_cache: MutableMapping[int, int] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return] + default_factory=lambda: LRU(MAX_CACHED_SERVICES) ) def get_gatt_services_cache( @@ -83,11 +82,13 @@ class DomainData: """Check whether the given entry is loaded.""" return entry.entry_id in self._entry_datas - def get_or_create_store(self, hass: HomeAssistant, entry: ConfigEntry) -> Store: + def get_or_create_store( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> ESPHomeStorage: """Get or create a Store instance for the given config entry.""" return self._stores.setdefault( entry.entry_id, - Store( + ESPHomeStorage( hass, STORAGE_VERSION, f"esphome.{entry.entry_id}", encoder=JSONEncoder ), ) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py new file mode 100644 index 00000000000..15c136f17c3 --- /dev/null +++ b/homeassistant/components/esphome/entity.py @@ -0,0 +1,295 @@ +"""Support for esphome entities.""" +from __future__ import annotations + +from collections.abc import Callable +import functools +import math +from typing import ( # pylint: disable=unused-import + Any, + Generic, + TypeVar, + cast, +) + +from aioesphomeapi import ( + EntityCategory as EsphomeEntityCategory, + EntityInfo, + EntityState, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + EntityCategory, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, +) +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .domain_data import DomainData + +# Import config flow so that it's added to the registry +from .entry_data import RuntimeEntryData +from .enum_mapper import EsphomeEnumMapper + +_R = TypeVar("_R") +_InfoT = TypeVar("_InfoT", bound=EntityInfo) +_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") +_StateT = TypeVar("_StateT", bound=EntityState) + + +async def platform_async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + *, + info_type: type[_InfoT], + entity_type: type[_EntityT], + state_type: type[_StateT], +) -> None: + """Set up an esphome platform. + + This method is in charge of receiving, distributing and storing + info and state updates. + """ + entry_data: RuntimeEntryData = DomainData.get(hass).get_entry_data(entry) + entry_data.info[info_type] = {} + entry_data.state.setdefault(state_type, {}) + + @callback + def async_list_entities(infos: list[EntityInfo]) -> None: + """Update entities of this platform when entities are listed.""" + current_infos = entry_data.info[info_type] + new_infos: dict[int, EntityInfo] = {} + add_entities: list[_EntityT] = [] + + for info in infos: + if not current_infos.pop(info.key, None): + # Create new entity + entity = entity_type(entry_data, info, state_type) + add_entities.append(entity) + new_infos[info.key] = info + + # Anything still in current_infos is now gone + if current_infos: + hass.async_create_task( + entry_data.async_remove_entities(current_infos.values()) + ) + + # Then update the actual info + entry_data.info[info_type] = new_infos + + if new_infos: + entry_data.async_update_entity_infos(new_infos.values()) + + if add_entities: + # Add entities to Home Assistant + async_add_entities(add_entities) + + entry_data.cleanup_callbacks.append( + entry_data.async_register_static_info_callback(info_type, async_list_entities) + ) + + +def esphome_state_property( + func: Callable[[_EntityT], _R] +) -> Callable[[_EntityT], _R | None]: + """Wrap a state property of an esphome entity. + + This checks if the state object in the entity is set, and + prevents writing NAN values to the Home Assistant state machine. + """ + + @functools.wraps(func) + def _wrapper(self: _EntityT) -> _R | None: + # pylint: disable-next=protected-access + if not self._has_state: + return None + val = func(self) + if isinstance(val, float) and math.isnan(val): + # Home Assistant doesn't use NAN values in state machine + # (not JSON serializable) + return None + return val + + return _wrapper + + +ICON_SCHEMA = vol.Schema(cv.icon) + + +ENTITY_CATEGORIES: EsphomeEnumMapper[ + EsphomeEntityCategory, EntityCategory | None +] = EsphomeEnumMapper( + { + EsphomeEntityCategory.NONE: None, + EsphomeEntityCategory.CONFIG: EntityCategory.CONFIG, + EsphomeEntityCategory.DIAGNOSTIC: EntityCategory.DIAGNOSTIC, + } +) + + +class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): + """Define a base esphome entity.""" + + _attr_should_poll = False + _static_info: _InfoT + _state: _StateT + _has_state: bool + + def __init__( + self, + entry_data: RuntimeEntryData, + entity_info: EntityInfo, + state_type: type[_StateT], + ) -> None: + """Initialize.""" + self._entry_data = entry_data + self._on_entry_data_changed() + self._key = entity_info.key + self._state_type = state_type + self._on_static_info_update(entity_info) + assert entry_data.device_info is not None + device_info = entry_data.device_info + self._device_info = device_info + self._attr_has_entity_name = bool(device_info.friendly_name) + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + self._entry_id = entry_data.entry_id + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + entry_data = self._entry_data + hass = self.hass + key = self._key + + self.async_on_remove( + entry_data.async_register_key_static_info_remove_callback( + self._static_info, + functools.partial(self.async_remove, force_remove=True), + ) + ) + self.async_on_remove( + async_dispatcher_connect( + hass, + entry_data.signal_device_updated, + self._on_device_update, + ) + ) + self.async_on_remove( + entry_data.async_subscribe_state_update( + self._state_type, key, self._on_state_update + ) + ) + self.async_on_remove( + entry_data.async_register_key_static_info_updated_callback( + self._static_info, self._on_static_info_update + ) + ) + self._update_state_from_entry_data() + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Save the static info for this entity when it changes. + + This method can be overridden in child classes to know + when the static info changes. + """ + static_info = cast(_InfoT, static_info) + self._static_info = static_info + self._attr_unique_id = static_info.unique_id + 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: + self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) + else: + self._attr_entity_category = None + if icon := static_info.icon: + self._attr_icon = cast(str, ICON_SCHEMA(icon)) + else: + self._attr_icon = None + + @callback + def _update_state_from_entry_data(self) -> None: + """Update state from entry data.""" + + state = self._entry_data.state + key = self._key + state_type = self._state_type + has_state = key in state[state_type] + if has_state: + self._state = cast(_StateT, state[state_type][key]) + self._has_state = has_state + + @callback + def _on_state_update(self) -> None: + """Call when state changed. + + Behavior can be changed in child classes + """ + self._update_state_from_entry_data() + self.async_write_ha_state() + + @callback + def _on_entry_data_changed(self) -> None: + entry_data = self._entry_data + self._api_version = entry_data.api_version + self._client = entry_data.client + + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + self._on_entry_data_changed() + if not self._entry_data.available: + # Only write state if the device has gone unavailable + # since _on_state_update will be called if the device + # is available when the full state arrives + # through the next entity state packet. + self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return if the entity is available.""" + if self._device_info.has_deep_sleep: + # During deep sleep the ESP will not be connectable (by design) + # For these cases, show it as available + return self._entry_data.expected_disconnect + + return self._entry_data.available + + +class EsphomeAssistEntity(Entity): + """Define a base entity for Assist Pipeline entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, entry_data: RuntimeEntryData) -> None: + """Initialize the binary sensor.""" + self._entry_data: RuntimeEntryData = entry_data + assert entry_data.device_info is not None + device_info = entry_data.device_info + self._device_info = device_info + self._attr_unique_id = ( + f"{device_info.mac_address}-{self.entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + + @callback + def _update(self) -> None: + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + await super().async_added_to_hass() + self.async_on_remove( + self._entry_data.async_subscribe_assist_pipeline_update(self._update) + ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 225ae3961e8..a7c81543a94 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -2,13 +2,14 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, Final, TypedDict, cast from aioesphomeapi import ( COMPONENT_TYPE_TO_INFO, + AlarmControlPanelInfo, APIClient, APIVersion, BinarySensorInfo, @@ -34,18 +35,21 @@ from aioesphomeapi.model import ButtonInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store from .dashboard import async_get_dashboard +INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()} + _SENTINEL = object() SAVE_DELAY = 120 _LOGGER = logging.getLogger(__name__) # Mapping from ESPHome info type to HA platform INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { + AlarmControlPanelInfo: Platform.ALARM_CONTROL_PANEL, BinarySensorInfo: Platform.BINARY_SENSOR, ButtonInfo: Platform.BUTTON, CameraInfo: Platform.CAMERA, @@ -63,28 +67,34 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { } +class StoreData(TypedDict, total=False): + """ESPHome storage data.""" + + device_info: dict[str, Any] + services: list[dict[str, Any]] + api_version: dict[str, Any] + + +class ESPHomeStorage(Store[StoreData]): + """ESPHome Storage.""" + + @dataclass class RuntimeEntryData: """Store runtime data for esphome config entries.""" entry_id: str client: APIClient - store: Store + store: ESPHomeStorage state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict) # When the disconnect callback is called, we mark all states # as stale so we will always dispatch a state update when the # device reconnects. This is the same format as state_subscriptions. stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set) - info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) - - # A second list of EntityInfo objects - # This is necessary for when an entity is being removed. HA requires - # some static info to be accessible during removal (unique_id, maybe others) - # If an entity can't find anything in the info array, it will look for info here. - old_info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) - + info: dict[type[EntityInfo], dict[int, EntityInfo]] = field(default_factory=dict) services: dict[int, UserService] = field(default_factory=dict) available: bool = False + expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep) device_info: DeviceInfo | None = None api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) @@ -94,7 +104,8 @@ class RuntimeEntryData: ] = field(default_factory=dict) loaded_platforms: set[Platform] = field(default_factory=set) platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) - _storage_contents: dict[str, Any] | None = None + _storage_contents: StoreData | None = None + _pending_storage: Callable[[], StoreData] | None = None ble_connections_free: int = 0 ble_connections_limit: int = 0 _ble_connection_free_futures: list[asyncio.Future[int]] = field( @@ -104,6 +115,16 @@ class RuntimeEntryData: default_factory=list ) assist_pipeline_state: bool = False + entity_info_callbacks: dict[ + type[EntityInfo], list[Callable[[list[EntityInfo]], None]] + ] = field(default_factory=dict) + entity_info_key_remove_callbacks: dict[ + tuple[type[EntityInfo], int], list[Callable[[], Coroutine[Any, Any, None]]] + ] = field(default_factory=dict) + entity_info_key_updated_callbacks: dict[ + tuple[type[EntityInfo], int], list[Callable[[EntityInfo], None]] + ] = field(default_factory=dict) + original_options: dict[str, Any] = field(default_factory=dict) @property def name(self) -> str: @@ -127,6 +148,53 @@ class RuntimeEntryData: """Return the signal to listen to for updates on static info.""" return f"esphome_{self.entry_id}_on_list" + @callback + def async_register_static_info_callback( + self, + entity_info_type: type[EntityInfo], + callback_: Callable[[list[EntityInfo]], None], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when static info changes for an EntityInfo type.""" + callbacks = self.entity_info_callbacks.setdefault(entity_info_type, []) + callbacks.append(callback_) + + def _unsub() -> None: + callbacks.remove(callback_) + + return _unsub + + @callback + def async_register_key_static_info_remove_callback( + self, + static_info: EntityInfo, + callback_: Callable[[], Coroutine[Any, Any, None]], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when static info is removed for a specific key.""" + callback_key = (type(static_info), static_info.key) + callbacks = self.entity_info_key_remove_callbacks.setdefault(callback_key, []) + callbacks.append(callback_) + + def _unsub() -> None: + callbacks.remove(callback_) + + return _unsub + + @callback + def async_register_key_static_info_updated_callback( + self, + static_info: EntityInfo, + callback_: Callable[[EntityInfo], None], + ) -> CALLBACK_TYPE: + """Register to receive callbacks when static info is updated for a specific key.""" + callback_key = (type(static_info), static_info.key) + callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) + callbacks.append(callback_) + + def _unsub() -> None: + callbacks.remove(callback_) + + return _unsub + @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: """Update the BLE connection limits.""" @@ -176,13 +244,25 @@ class RuntimeEntryData: self.assist_pipeline_update_callbacks.append(update_callback) return _unsubscribe - @callback - def async_remove_entity( - self, hass: HomeAssistant, component_key: str, key: int - ) -> None: + async def async_remove_entities(self, static_infos: Iterable[EntityInfo]) -> None: """Schedule the removal of an entity.""" - signal = f"esphome_{self.entry_id}_remove_{component_key}_{key}" - async_dispatcher_send(hass, signal) + callbacks: list[Coroutine[Any, Any, None]] = [] + for static_info in static_infos: + callback_key = (type(static_info), static_info.key) + if key_callbacks := self.entity_info_key_remove_callbacks.get(callback_key): + callbacks.extend([callback_() for callback_ in key_callbacks]) + if callbacks: + await asyncio.gather(*callbacks) + + @callback + def async_update_entity_infos(self, static_infos: Iterable[EntityInfo]) -> None: + """Call static info updated callbacks.""" + for static_info in static_infos: + callback_key = (type(static_info), static_info.key) + for callback_ in self.entity_info_key_updated_callbacks.get( + callback_key, [] + ): + callback_(static_info) async def _ensure_platforms_loaded( self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[Platform] @@ -214,6 +294,21 @@ class RuntimeEntryData: break await self._ensure_platforms_loaded(hass, entry, needed_platforms) + # Make a dict of the EntityInfo by type and send + # them to the listeners for each specific EntityInfo type + infos_by_type: dict[type[EntityInfo], list[EntityInfo]] = {} + for info in infos: + info_type = type(info) + if info_type not in infos_by_type: + infos_by_type[info_type] = [] + infos_by_type[info_type].append(info) + + callbacks_by_type = self.entity_info_callbacks + for type_, entity_infos in infos_by_type.items(): + if callbacks_ := callbacks_by_type.get(type_): + for callback_ in callbacks_: + callback_(entity_infos) + # Then send dispatcher event async_dispatcher_send(hass, self.signal_static_info_updated, infos) @@ -246,7 +341,7 @@ class RuntimeEntryData: and subscription_key not in stale_state and not ( type(state) is SensorState # pylint: disable=unidiomatic-typecheck - and (platform_info := self.info.get(Platform.SENSOR)) + and (platform_info := self.info.get(SensorInfo)) and (entity_info := platform_info.get(state.key)) and (cast(SensorInfo, entity_info)).force_update ) @@ -284,43 +379,61 @@ class RuntimeEntryData: """Load the retained data from store and return de-serialized data.""" if (restored := await self.store.async_load()) is None: return [], [] - restored = cast("dict[str, Any]", restored) self._storage_contents = restored.copy() self.device_info = DeviceInfo.from_dict(restored.pop("device_info")) self.api_version = APIVersion.from_dict(restored.pop("api_version", {})) - infos = [] + infos: list[EntityInfo] = [] for comp_type, restored_infos in restored.items(): + if TYPE_CHECKING: + restored_infos = cast(list[dict[str, Any]], restored_infos) if comp_type not in COMPONENT_TYPE_TO_INFO: continue for info in restored_infos: cls = COMPONENT_TYPE_TO_INFO[comp_type] infos.append(cls.from_dict(info)) - services = [] - for service in restored.get("services", []): - services.append(UserService.from_dict(service)) + services = [ + UserService.from_dict(service) for service in restored.pop("services", []) + ] return infos, services async def async_save_to_store(self) -> None: """Generate dynamic data to store and save it to the filesystem.""" if self.device_info is None: raise ValueError("device_info is not set yet") - store_data: dict[str, Any] = { + store_data: StoreData = { "device_info": self.device_info.to_dict(), "services": [], "api_version": self.api_version.to_dict(), } - - for comp_type, infos in self.info.items(): - store_data[comp_type] = [info.to_dict() for info in infos.values()] + for info_type, infos in self.info.items(): + comp_type = INFO_TO_COMPONENT_TYPE[info_type] + store_data[comp_type] = [info.to_dict() for info in infos.values()] # type: ignore[literal-required] for service in self.services.values(): store_data["services"].append(service.to_dict()) if store_data == self._storage_contents: return - def _memorized_storage() -> dict[str, Any]: + def _memorized_storage() -> StoreData: + self._pending_storage = None self._storage_contents = store_data return store_data + self._pending_storage = _memorized_storage self.store.async_delay_save(_memorized_storage, SAVE_DELAY) + + async def async_cleanup(self) -> None: + """Cleanup the entry data when disconnected or unloading.""" + if self._pending_storage: + # Ensure we save the data if we are unloading before the + # save delay has passed. + await self.store.async_save(self._pending_storage()) + + async def async_update_listener( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Handle options update.""" + if self.original_options == entry.options: + return + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 01060630964..c6be200e2b2 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -4,7 +4,7 @@ from __future__ import annotations import math from typing import Any -from aioesphomeapi import FanDirection, FanInfo, FanSpeed, FanState +from aioesphomeapi import EntityInfo, FanDirection, FanInfo, FanSpeed, FanState from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -13,7 +13,7 @@ from homeassistant.components.fan import ( FanEntityFeature, ) 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.util.percentage import ( ordered_list_item_to_percentage, @@ -22,7 +22,11 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] @@ -36,7 +40,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="fan", info_type=FanInfo, entity_type=EsphomeFan, state_type=FanState, @@ -68,7 +71,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): await self.async_turn_off() return - data: dict[str, Any] = {"key": self._static_info.key, "state": True} + data: dict[str, Any] = {"key": self._key, "state": True} if percentage is not None: if self._supports_speed_levels: data["speed_level"] = math.ceil( @@ -94,18 +97,16 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - await self._client.fan_command(key=self._static_info.key, state=False) + await self._client.fan_command(key=self._key, state=False) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - await self._client.fan_command( - key=self._static_info.key, oscillating=oscillating - ) + await self._client.fan_command(key=self._key, oscillating=oscillating) async def async_set_direction(self, direction: str) -> None: """Set direction of the fan.""" await self._client.fan_command( - key=self._static_info.key, direction=_FAN_DIRECTIONS.from_hass(direction) + key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) @property @@ -141,26 +142,24 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @esphome_state_property def oscillating(self) -> bool | None: """Return the oscillation state.""" - if not self._static_info.supports_oscillation: - return None return self._state.oscillating @property @esphome_state_property def current_direction(self) -> str | None: """Return the current fan direction.""" - if not self._static_info.supports_direction: - return None return _FAN_DIRECTIONS.from_esphome(self._state.direction) - @property - def supported_features(self) -> FanEntityFeature: - """Flag supported features.""" + @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 flags = FanEntityFeature(0) - if self._static_info.supports_oscillation: + if static_info.supports_oscillation: flags |= FanEntityFeature.OSCILLATE - if self._static_info.supports_speed: + if static_info.supports_speed: flags |= FanEntityFeature.SET_SPEED - if self._static_info.supports_direction: + if static_info.supports_direction: flags |= FanEntityFeature.DIRECTION - return flags + self._attr_supported_features = flags diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 5e16f2476bb..1ecc99730bf 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,11 +3,17 @@ from __future__ import annotations from typing import Any, cast -from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState +from aioesphomeapi import ( + APIVersion, + EntityInfo, + LightColorCapability, + LightInfo, + LightState, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, @@ -22,10 +28,14 @@ from homeassistant.components.light import ( LightEntityFeature, ) 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 . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -38,7 +48,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="light", info_type=LightInfo, entity_type=EsphomeLight, state_type=LightState, @@ -92,6 +101,20 @@ _COLOR_MODE_MAPPING = { } +def _mired_to_kelvin(mired_temperature: float) -> int: + """Convert absolute mired shift to degrees kelvin. + + This function rounds the converted value instead of flooring the value as + is done in homeassistant.util.color.color_temperature_mired_to_kelvin(). + + If the value of mired_temperature is less than or equal to zero, return + the original value to avoid a divide by zero. + """ + if mired_temperature <= 0: + return round(mired_temperature) + return round(1000000 / mired_temperature) + + def _color_mode_to_ha(mode: int) -> str: """Convert an esphome color mode to a HA color mode constant. @@ -128,10 +151,8 @@ def _filter_color_modes( class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """A light implementation for ESPHome.""" - @property - def _supports_color_mode(self) -> bool: - """Return whether the client supports the new color mode system natively.""" - return self._api_version >= APIVersion(1, 6) + _native_supported_color_modes: list[int] + _supports_color_mode = False @property @esphome_state_property @@ -141,7 +162,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - data: dict[str, Any] = {"key": self._static_info.key, "state": True} + data: dict[str, Any] = {"key": self._key, "state": True} # The list of color modes that would fit this service call color_modes = self._native_supported_color_modes try_keep_current_mode = True @@ -194,8 +215,9 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # need to convert cw+ww part to white+color_temp white = data["white"] = max(cw, ww) if white != 0: - min_ct = self.min_mireds - max_ct = self.max_mireds + static_info = self._static_info + min_ct = static_info.min_mireds + max_ct = static_info.max_mireds ct_ratio = ww / (cw + ww) data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) color_modes = _filter_color_modes( @@ -212,8 +234,9 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (transition := kwargs.get(ATTR_TRANSITION)) is not None: data["transition_length"] = transition - if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: - data["color_temperature"] = color_temp + if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + # Do not use kelvin_to_mired here to prevent precision loss + data["color_temperature"] = 1000000.0 / color_temp_k if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): color_modes = _filter_color_modes( color_modes, LightColorCapability.COLOR_TEMPERATURE @@ -259,7 +282,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - data: dict[str, Any] = {"key": self._static_info.key, "state": False} + data: dict[str, Any] = {"key": self._key, "state": False} if ATTR_FLASH in kwargs: data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: @@ -287,17 +310,18 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @esphome_state_property def rgb_color(self) -> tuple[int, int, int] | None: """Return the rgb color value [int, int, int].""" + state = self._state if not self._supports_color_mode: return ( - round(self._state.red * 255), - round(self._state.green * 255), - round(self._state.blue * 255), + round(state.red * 255), + round(state.green * 255), + round(state.blue * 255), ) return ( - round(self._state.red * self._state.color_brightness * 255), - round(self._state.green * self._state.color_brightness * 255), - round(self._state.blue * self._state.color_brightness * 255), + round(state.red * state.color_brightness * 255), + round(state.green * state.color_brightness * 255), + round(state.blue * state.color_brightness * 255), ) @property @@ -312,15 +336,17 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @esphome_state_property def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" + state = self._state rgb = cast("tuple[int, int, int]", self.rgb_color) if not _filter_color_modes( self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE ): # Try to reverse white + color temp to cwww - min_ct = self._static_info.min_mireds - max_ct = self._static_info.max_mireds - color_temp = min(max(self._state.color_temperature, min_ct), max_ct) - white = self._state.white + static_info = self._static_info + min_ct = static_info.min_mireds + max_ct = static_info.max_mireds + color_temp = min(max(state.color_temperature, min_ct), max_ct) + white = state.white ww_frac = (color_temp - min_ct) / (max_ct - min_ct) cw_frac = 1 - ww_frac @@ -332,15 +358,15 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): ) return ( *rgb, - round(self._state.cold_white * 255), - round(self._state.warm_white * 255), + round(state.cold_white * 255), + round(state.warm_white * 255), ) @property @esphome_state_property - def color_temp(self) -> int: - """Return the CT color value in mireds.""" - return round(self._state.color_temperature) + def color_temp_kelvin(self) -> int: + """Return the CT color value in Kelvin.""" + return _mired_to_kelvin(self._state.color_temperature) @property @esphome_state_property @@ -348,26 +374,25 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """Return the current effect.""" return self._state.effect - @property - def _native_supported_color_modes(self) -> list[int]: - return self._static_info.supported_color_modes_compat(self._api_version) - - @property - def supported_features(self) -> LightEntityFeature: - """Flag supported features.""" + @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._supports_color_mode = self._api_version >= APIVersion(1, 6) + self._native_supported_color_modes = static_info.supported_color_modes_compat( + self._api_version + ) flags = LightEntityFeature.FLASH # All color modes except UNKNOWN,ON_OFF support transition modes = self._native_supported_color_modes if any(m not in (0, LightColorCapability.ON_OFF) for m in modes): flags |= LightEntityFeature.TRANSITION - if self._static_info.effects: + if static_info.effects: flags |= LightEntityFeature.EFFECT - return flags + self._attr_supported_features = flags - @property - def supported_color_modes(self) -> set[str] | None: - """Flag supported color modes.""" supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) if ColorMode.ONOFF in supported and len(supported) > 1: supported.remove(ColorMode.ONOFF) @@ -375,19 +400,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): supported.remove(ColorMode.BRIGHTNESS) if ColorMode.WHITE in supported and len(supported) == 1: supported.remove(ColorMode.WHITE) - return supported - - @property - def effect_list(self) -> list[str]: - """Return the list of supported effects.""" - return self._static_info.effects - - @property - def min_mireds(self) -> int: - """Return the coldest color_temp that this light supports.""" - return round(self._static_info.min_mireds) - - @property - def max_mireds(self) -> int: - """Return the warmest color_temp that this light supports.""" - return round(self._static_info.max_mireds) + self._attr_supported_color_modes = supported + self._attr_effect_list = static_info.effects + self._attr_min_mireds = round(static_info.min_mireds) + self._attr_max_mireds = round(static_info.max_mireds) + if ColorMode.COLOR_TEMP in supported: + self._attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds) + self._attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds) diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index 947ea4729bb..00b94cd15ff 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -3,15 +3,19 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import LockCommand, LockEntityState, LockInfo, LockState +from aioesphomeapi import EntityInfo, LockCommand, LockEntityState, LockInfo, LockState from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( @@ -22,7 +26,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="lock", info_type=LockInfo, entity_type=EsphomeLock, state_type=LockEntityState, @@ -32,24 +35,19 @@ async def async_setup_entry( class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): """A lock implementation for ESPHome.""" - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return self._static_info.assumed_state - - @property - def supported_features(self) -> LockEntityFeature: - """Flag supported features.""" - if self._static_info.supports_open: - return LockEntityFeature.OPEN - return LockEntityFeature(0) - - @property - def code_format(self) -> str | None: - """Regex for code format or None if no code is required.""" - if self._static_info.requires_code: - return self._static_info.code_format - return None + @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_assumed_state = static_info.assumed_state + self._attr_supported_features = LockEntityFeature(0) + if static_info.supports_open: + self._attr_supported_features |= LockEntityFeature.OPEN + if static_info.requires_code: + self._attr_code_format = static_info.code_format + else: + self._attr_code_format = None @property @esphome_state_property @@ -77,13 +75,13 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - await self._client.lock_command(self._static_info.key, LockCommand.LOCK) + await self._client.lock_command(self._key, LockCommand.LOCK) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" code = kwargs.get(ATTR_CODE, None) - await self._client.lock_command(self._static_info.key, LockCommand.UNLOCK, code) + await self._client.lock_command(self._key, LockCommand.UNLOCK, code) async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - await self._client.lock_command(self._static_info.key, LockCommand.OPEN) + await self._client.lock_command(self._key, LockCommand.OPEN) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index c6e430d7845..8f5e6b95c39 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -2,7 +2,7 @@ "domain": "esphome", "name": "ESPHome", "after_dependencies": ["zeroconf", "tag"], - "codeowners": ["@OttoWinter", "@jesserockz"], + "codeowners": ["@OttoWinter", "@jesserockz", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth"], "dhcp": [ @@ -15,8 +15,8 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==13.9.0", - "bluetooth-data-tools==0.4.0", + "aioesphomeapi==15.1.1", + "bluetooth-data-tools==1.3.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index d818e040965..9d008300966 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from aioesphomeapi import ( + EntityInfo, MediaPlayerCommand, MediaPlayerEntityState, MediaPlayerInfo, @@ -21,10 +22,14 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) 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 . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper @@ -38,7 +43,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="media_player", info_type=MediaPlayerInfo, entity_type=EsphomeMediaPlayer, state_type=MediaPlayerEntityState, @@ -61,6 +65,21 @@ class EsphomeMediaPlayer( _attr_device_class = MediaPlayerDeviceClass.SPEAKER + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + flags = ( + MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + ) + if self._static_info.supports_pause: + flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY + self._attr_supported_features = flags + @property @esphome_state_property def state(self) -> MediaPlayerState | None: @@ -79,20 +98,6 @@ class EsphomeMediaPlayer( """Volume level of the media player (0..1).""" return self._state.volume - @property - def supported_features(self) -> MediaPlayerEntityFeature: - """Flag supported features.""" - flags = ( - MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.BROWSE_MEDIA - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_MUTE - ) - if self._static_info.supports_pause: - flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY - return flags - async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -106,7 +111,7 @@ class EsphomeMediaPlayer( media_id = async_process_play_media_url(self.hass, media_id) await self._client.media_player_command( - self._static_info.key, + self._key, media_url=media_id, ) @@ -124,35 +129,29 @@ class EsphomeMediaPlayer( async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - await self._client.media_player_command( - self._static_info.key, - volume=volume, - ) + await self._client.media_player_command(self._key, volume=volume) async def async_media_pause(self) -> None: """Send pause command.""" await self._client.media_player_command( - self._static_info.key, - command=MediaPlayerCommand.PAUSE, + self._key, command=MediaPlayerCommand.PAUSE ) async def async_media_play(self) -> None: """Send play command.""" await self._client.media_player_command( - self._static_info.key, - command=MediaPlayerCommand.PLAY, + self._key, command=MediaPlayerCommand.PLAY ) async def async_media_stop(self) -> None: """Send stop command.""" await self._client.media_player_command( - self._static_info.key, - command=MediaPlayerCommand.STOP, + self._key, command=MediaPlayerCommand.STOP ) async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" await self._client.media_player_command( - self._static_info.key, + self._key, command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE, ) diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 3ca8e0b9728..4e3d052e6ef 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -3,15 +3,24 @@ from __future__ import annotations import math -from aioesphomeapi import NumberInfo, NumberMode as EsphomeNumberMode, NumberState +from aioesphomeapi import ( + EntityInfo, + NumberInfo, + NumberMode as EsphomeNumberMode, + NumberState, +) from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode 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.util.enum import try_parse_enum -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper @@ -25,7 +34,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="number", info_type=NumberInfo, entity_type=EsphomeNumber, state_type=NumberState, @@ -44,48 +52,32 @@ NUMBER_MODES: EsphomeEnumMapper[EsphomeNumberMode, NumberMode] = EsphomeEnumMapp class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): """A number implementation for esphome.""" - @property - def device_class(self) -> NumberDeviceClass | None: - """Return the class of this entity.""" - return try_parse_enum(NumberDeviceClass, self._static_info.device_class) - - @property - def native_min_value(self) -> float: - """Return the minimum value.""" - return super()._static_info.min_value - - @property - def native_max_value(self) -> float: - """Return the maximum value.""" - return super()._static_info.max_value - - @property - def native_step(self) -> float: - """Return the increment/decrement step.""" - return super()._static_info.step - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - return super()._static_info.unit_of_measurement - - @property - def mode(self) -> NumberMode: - """Return the mode of the entity.""" - if self._static_info.mode: - return NUMBER_MODES.from_esphome(self._static_info.mode) - return NumberMode.AUTO + @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_device_class = try_parse_enum( + NumberDeviceClass, self._static_info.device_class + ) + self._attr_native_min_value = static_info.min_value + self._attr_native_max_value = static_info.max_value + self._attr_native_step = static_info.step + self._attr_native_unit_of_measurement = static_info.unit_of_measurement + if mode := static_info.mode: + self._attr_mode = NUMBER_MODES.from_esphome(mode) + else: + self._attr_mode = NumberMode.AUTO @property @esphome_state_property def native_value(self) -> float | None: """Return the state of the entity.""" - if math.isnan(self._state.state): + state = self._state + if state.missing_state or math.isnan(state.state): return None - if self._state.missing_state: - return None - return self._state.state + return state.state async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self._client.number_command(self._static_info.key, value) + await self._client.number_command(self._key, value) diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index e4cac21dbc8..a3464b137dc 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -1,21 +1,24 @@ """Support for esphome selects.""" from __future__ import annotations -from aioesphomeapi import SelectInfo, SelectState +from aioesphomeapi import EntityInfo, SelectInfo, SelectState -from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.components.assist_pipeline.select import ( + AssistPipelineSelect, + VadSensitivitySelect, +) from homeassistant.components.select import SelectEntity 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 . import ( +from .domain_data import DomainData +from .entity import ( EsphomeAssistEntity, EsphomeEntity, esphome_state_property, platform_async_setup_entry, ) -from .domain_data import DomainData from .entry_data import RuntimeEntryData @@ -29,7 +32,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="select", info_type=SelectInfo, entity_type=EsphomeSelect, state_type=SelectState, @@ -38,28 +40,33 @@ async def async_setup_entry( entry_data = DomainData.get(hass).get_entry_data(entry) assert entry_data.device_info is not None if entry_data.device_info.voice_assistant_version: - async_add_entities([EsphomeAssistPipelineSelect(hass, entry_data)]) + async_add_entities( + [ + EsphomeAssistPipelineSelect(hass, entry_data), + EsphomeVadSensitivitySelect(hass, entry_data), + ] + ) class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): """A select implementation for esphome.""" - @property - def options(self) -> list[str]: - """Return a set of selectable options.""" - return self._static_info.options + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + self._attr_options = self._static_info.options @property @esphome_state_property def current_option(self) -> str | None: """Return the state of the entity.""" - if self._state.missing_state: - return None - return self._state.state + state = self._state + return None if state.missing_state else state.state async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self._client.select_command(self._static_info.key, option) + await self._client.select_command(self._key, option) class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): @@ -69,3 +76,12 @@ class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): """Initialize a pipeline selector.""" EsphomeAssistEntity.__init__(self, entry_data) AssistPipelineSelect.__init__(self, hass, self._device_info.mac_address) + + +class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect): + """VAD sensitivity selector for VoIP devices.""" + + def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: + """Initialize a VAD sensitivity selector.""" + EsphomeAssistEntity.__init__(self, entry_data) + VadSensitivitySelect.__init__(self, hass, self._device_info.mac_address) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 7a1234341be..3185a5eb536 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -5,6 +5,7 @@ from datetime import datetime import math from aioesphomeapi import ( + EntityInfo, SensorInfo, SensorState, SensorStateClass as EsphomeSensorStateClass, @@ -19,12 +20,16 @@ from homeassistant.components.sensor import ( SensorStateClass, ) 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.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) from .enum_mapper import EsphomeEnumMapper @@ -36,7 +41,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="sensor", info_type=SensorInfo, entity_type=EsphomeSensor, state_type=SensorState, @@ -45,7 +49,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="text_sensor", info_type=TextSensorInfo, entity_type=EsphomeTextSensor, state_type=TextSensorState, @@ -67,50 +70,38 @@ _STATE_CLASSES: EsphomeEnumMapper[ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): """A sensor implementation for esphome.""" - @property - def force_update(self) -> bool: - """Return if this sensor should force a state update.""" - return self._static_info.force_update + @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_force_update = static_info.force_update + self._attr_native_unit_of_measurement = static_info.unit_of_measurement + self._attr_device_class = try_parse_enum( + SensorDeviceClass, static_info.device_class + ) + if not (state_class := static_info.state_class): + return + if ( + state_class == EsphomeSensorStateClass.MEASUREMENT + and static_info.last_reset_type == LastResetType.AUTO + ): + # Legacy, last_reset_type auto was the equivalent to the + # TOTAL_INCREASING state class + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + else: + self._attr_state_class = _STATE_CLASSES.from_esphome(state_class) @property @esphome_state_property def native_value(self) -> datetime | str | None: """Return the state of the entity.""" - if math.isnan(self._state.state): + state = self._state + if math.isnan(state.state) or state.missing_state: return None - if self._state.missing_state: - return None - if self.device_class == SensorDeviceClass.TIMESTAMP: - return dt_util.utc_from_timestamp(self._state.state) - return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - if not self._static_info.unit_of_measurement: - return None - return self._static_info.unit_of_measurement - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return try_parse_enum(SensorDeviceClass, self._static_info.device_class) - - @property - def state_class(self) -> SensorStateClass | None: - """Return the state class of this entity.""" - if not self._static_info.state_class: - return None - state_class = self._static_info.state_class - reset_type = self._static_info.last_reset_type - if ( - state_class == EsphomeSensorStateClass.MEASUREMENT - and reset_type == LastResetType.AUTO - ): - # Legacy, last_reset_type auto was the equivalent to the - # TOTAL_INCREASING state class - return SensorStateClass.TOTAL_INCREASING - return _STATE_CLASSES.from_esphome(self._static_info.state_class) + if self._attr_device_class == SensorDeviceClass.TIMESTAMP: + return dt_util.utc_from_timestamp(state.state) + return f"{state.state:.{self._static_info.accuracy_decimals}f}" class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): @@ -120,6 +111,5 @@ class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEn @esphome_state_property def native_value(self) -> str | None: """Return the state of the entity.""" - if self._state.missing_state: - return None - return self._state.state + state = self._state + return None if state.missing_state else state.state diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 81350c2c653..2ec1fe1bc41 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -46,6 +46,15 @@ }, "flow_title": "{name}" }, + "options": { + "step": { + "init": { + "data": { + "allow_service_calls": "Allow the device to make Home Assistant service calls." + } + } + } + }, "entity": { "binary_sensor": { "assist_in_progress": { @@ -58,6 +67,14 @@ "state": { "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" } + }, + "vad_sensitivity": { + "name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]", + "state": { + "default": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::default%]", + "aggressive": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::aggressive%]", + "relaxed": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::relaxed%]" + } } } }, @@ -69,6 +86,10 @@ "api_password_deprecated": { "title": "API Password deprecated on {name}", "description": "The API password for ESPHome is deprecated and the use of an API encryption key is recommended instead.\n\nRemove the API password and add an encryption key to your ESPHome device to resolve this issue." + }, + "service_calls_not_allowed": { + "title": "{name} is not permitted to call Home Assistant services", + "description": "The ESPHome device attempted to make a Home Assistant service call, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to make Home Assistant service calls, you can enable this functionality in the options flow." } } } diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 83148542435..99894b8501e 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -3,15 +3,19 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import SwitchInfo, SwitchState +from aioesphomeapi import EntityInfo, SwitchInfo, SwitchState from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity 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.util.enum import try_parse_enum -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .entity import ( + EsphomeEntity, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( @@ -22,7 +26,6 @@ async def async_setup_entry( hass, entry, async_add_entities, - component_key="switch", info_type=SwitchInfo, entity_type=EsphomeSwitch, state_type=SwitchState, @@ -32,10 +35,15 @@ async def async_setup_entry( class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): """A switch implementation for ESPHome.""" - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._static_info.assumed_state + @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_assumed_state = static_info.assumed_state + self._attr_device_class = try_parse_enum( + SwitchDeviceClass, static_info.device_class + ) @property @esphome_state_property @@ -43,15 +51,10 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): """Return true if the switch is on.""" return self._state.state - @property - def device_class(self) -> SwitchDeviceClass | None: - """Return the class of this device.""" - return try_parse_enum(SwitchDeviceClass, self._static_info.device_class) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self._client.switch_command(self._static_info.key, True) + await self._client.switch_command(self._key, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self._client.switch_command(self._static_info.key, False) + await self._client.switch_command(self._key, False) diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 618e31024b1..6f51b9df744 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -111,7 +111,11 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): """ return ( super().available - and (self._entry_data.available or self._device_info.has_deep_sleep) + and ( + self._entry_data.available + or self._entry_data.expected_disconnect + or self._device_info.has_deep_sleep + ) and self._device_info.name in self.coordinator.data ) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index efb4162ae1a..6b49549d812 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -19,7 +19,10 @@ from homeassistant.components.assist_pipeline import ( async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter +from homeassistant.components.assist_pipeline.vad import ( + VadSensitivity, + VoiceCommandSegmenter, +) from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -251,9 +254,9 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): chunk = await self.queue.get() async def _iterate_packets_with_vad( - self, pipeline_timeout: float + self, pipeline_timeout: float, silence_seconds: float ) -> Callable[[], AsyncIterable[bytes]] | None: - segmenter = VoiceCommandSegmenter() + segmenter = VoiceCommandSegmenter(silence_seconds=silence_seconds) chunk_buffer: deque[bytes] = deque(maxlen=100) try: async with async_timeout.timeout(pipeline_timeout): @@ -293,6 +296,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): async def run_pipeline( self, + device_id: str, conversation_id: str | None, use_vad: bool = False, pipeline_timeout: float = 30.0, @@ -304,7 +308,16 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): ) if use_vad: - stt_stream = await self._iterate_packets_with_vad(pipeline_timeout) + stt_stream = await self._iterate_packets_with_vad( + pipeline_timeout, + silence_seconds=VadSensitivity.to_seconds( + pipeline_select.get_vad_sensitivity( + self.hass, + DOMAIN, + self.device_info.mac_address, + ) + ), + ) # Error or timeout occurred and was handled already if stt_stream is None: return @@ -331,6 +344,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.hass, DOMAIN, self.device_info.mac_address ), conversation_id=conversation_id, + device_id=device_id, tts_audio_output=tts_audio_output, ) diff --git a/homeassistant/components/eufylife_ble/manifest.json b/homeassistant/components/eufylife_ble/manifest.json index ad70dd97d58..c3a2357ebca 100644 --- a/homeassistant/components/eufylife_ble/manifest.json +++ b/homeassistant/components/eufylife_ble/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/eufylife_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["eufylife_ble_client==0.1.7"] + "requirements": ["eufylife-ble-client==0.1.7"] } diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index d7c69dec165..741f71b34d2 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -86,7 +86,7 @@ class EufyLifeSensorEntity(SensorEntity): class EufyLifeRealTimeWeightSensorEntity(EufyLifeSensorEntity): """Representation of an EufyLife real-time weight sensor.""" - _attr_name = "Real-time weight" + _attr_translation_key = "real_time_weight" _attr_native_unit_of_measurement = UnitOfMass.KILOGRAMS _attr_device_class = SensorDeviceClass.WEIGHT @@ -115,7 +115,7 @@ class EufyLifeRealTimeWeightSensorEntity(EufyLifeSensorEntity): class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Representation of an EufyLife weight sensor.""" - _attr_name = "Weight" + _attr_translation_key = "weight" _attr_native_unit_of_measurement = UnitOfMass.KILOGRAMS _attr_device_class = SensorDeviceClass.WEIGHT @@ -176,7 +176,7 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Representation of an EufyLife heart rate sensor.""" - _attr_name = "Heart rate" + _attr_translation_key = "heart_rate" _attr_icon = "mdi:heart-pulse" _attr_native_unit_of_measurement = "bpm" diff --git a/homeassistant/components/eufylife_ble/strings.json b/homeassistant/components/eufylife_ble/strings.json index a045d84771e..5f7924f4cbd 100644 --- a/homeassistant/components/eufylife_ble/strings.json +++ b/homeassistant/components/eufylife_ble/strings.json @@ -18,5 +18,18 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "real_time_weight": { + "name": "Real-time weight" + }, + "weight": { + "name": "Weight" + }, + "heart_rate": { + "name": "Heart rate" + } + } } } diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index d4ee8f3d5df..3bee1d6062e 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -131,7 +131,7 @@ class EvoClimateEntity(EvoDevice, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS @property - def hvac_modes(self) -> list[str]: + def hvac_modes(self) -> list[HVACMode]: """Return a list of available hvac operation modes.""" return list(HA_HVAC_TO_TCS) @@ -191,7 +191,7 @@ class EvoZone(EvoChild, EvoClimateEntity): ) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Zone.""" if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): return HVACMode.AUTO @@ -356,7 +356,7 @@ class EvoController(EvoClimateEntity): ) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" tcs_mode = self._evo_tcs.systemModeStatus["mode"] return HVACMode.OFF if tcs_mode == EVO_HEATOFF else HVACMode.HEAT diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 2966c339f95..9386a407acb 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -35,6 +35,7 @@ PLATFORMS_BY_TYPE: dict[str, list] = { ATTR_TYPE_CLOUD: [ Platform.BINARY_SENSOR, Platform.CAMERA, + Platform.LIGHT, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 57be995a489..60a332446ce 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -312,7 +312,7 @@ class EzvizCamera(EzvizEntity, Camera): self.hass, DOMAIN, "service_depreciation_detection_sensibility", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=False, severity=ir.IssueSeverity.WARNING, translation_key="service_depreciation_detection_sensibility", diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 1c966c7f82e..ccf273a970b 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -38,3 +38,32 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): def data(self) -> dict[str, Any]: """Return coordinator data for this entity.""" return self.coordinator.data[self._serial] + + +class EzvizBaseEntity(Entity): + """Generic entity for EZVIZ individual poll entities.""" + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + ) -> None: + """Initialize the entity.""" + self._serial = serial + self.coordinator = coordinator + self._camera_name = self.data["name"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial)}, + connections={ + (CONNECTION_NETWORK_MAC, self.data["mac_address"]), + }, + manufacturer=MANUFACTURER, + model=self.data["device_sub_category"], + name=self.data["name"], + sw_version=self.data["version"], + ) + + @property + def data(self) -> dict[str, Any]: + """Return coordinator data for this entity.""" + return self.coordinator.data[self._serial] diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py new file mode 100644 index 00000000000..38007962e4e --- /dev/null +++ b/homeassistant/components/ezviz/light.py @@ -0,0 +1,125 @@ +"""Support for EZVIZ light entity.""" +from __future__ import annotations + +from typing import Any + +from pyezviz.constants import DeviceCatagories, DeviceSwitchType, SupportExt +from pyezviz.exceptions import HTTPError, PyEzvizError + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 +BRIGHTNESS_RANGE = (1, 255) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ lights based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + EzvizLight(coordinator, camera) + for camera in coordinator.data + for capibility, value in coordinator.data[camera]["supportExt"].items() + if capibility == str(SupportExt.SupportAlarmLight.value) + if value == "1" + ) + + +class EzvizLight(EzvizEntity, LightEntity): + """Representation of a EZVIZ light.""" + + _attr_has_entity_name = True + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + ) -> None: + """Initialize the light.""" + super().__init__(coordinator, serial) + self.battery_cam_type = bool( + self.data["device_category"] + == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value + ) + self._attr_unique_id = f"{serial}_Light" + self._attr_name = "Light" + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + return round( + percentage_to_ranged_value( + BRIGHTNESS_RANGE, + self.coordinator.data[self._serial]["alarm_light_luminance"], + ) + ) + + @property + def is_on(self) -> bool: + """Return the state of the light.""" + return self.data["switches"][DeviceSwitchType.ALARM_LIGHT.value] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + try: + if ATTR_BRIGHTNESS in kwargs: + data = ranged_value_to_percentage( + BRIGHTNESS_RANGE, kwargs[ATTR_BRIGHTNESS] + ) + + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.set_floodlight_brightness, + self._serial, + data, + ) + else: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, + self._serial, + DeviceSwitchType.ALARM_LIGHT.value, + 1, + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to turn on light {self._attr_name}" + ) from err + + if update_ok: + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + try: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, + self._serial, + DeviceSwitchType.ALARM_LIGHT.value, + 0, + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to turn off light {self._attr_name}" + ) from err + + if update_ok: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 219f4c87d13..53976bf3002 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.0.12"] + "requirements": ["pyezviz==0.2.1.2"] } diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 849bf2c400b..074685c69f9 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -1,8 +1,18 @@ """Support for EZVIZ number controls.""" from __future__ import annotations -from pyezviz.constants import DeviceCatagories -from pyezviz.exceptions import HTTPError, PyEzvizError +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pyezviz.constants import SupportExt +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry @@ -13,17 +23,37 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_COORDINATOR, DOMAIN from .coordinator import EzvizDataUpdateCoordinator -from .entity import EzvizEntity +from .entity import EzvizBaseEntity -PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(seconds=3600) +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) -NUMBER_TYPES = NumberEntityDescription( + +@dataclass +class EzvizNumberEntityDescriptionMixin: + """Mixin values for EZVIZ Number entities.""" + + supported_ext: str + supported_ext_value: list + + +@dataclass +class EzvizNumberEntityDescription( + NumberEntityDescription, EzvizNumberEntityDescriptionMixin +): + """Describe a EZVIZ Number.""" + + +NUMBER_TYPE = EzvizNumberEntityDescription( key="detection_sensibility", name="Detection sensitivity", icon="mdi:eye", entity_category=EntityCategory.CONFIG, native_min_value=0, native_step=1, + supported_ext=str(SupportExt.SupportSensibilityAdjust.value), + supported_ext_value=["1", "3"], ) @@ -36,15 +66,18 @@ async def async_setup_entry( ] async_add_entities( - EzvizSensor(coordinator, camera, sensor, NUMBER_TYPES) - for camera in coordinator.data - for sensor, value in coordinator.data[camera].items() - if sensor in NUMBER_TYPES.key - if value + [ + EzvizSensor(coordinator, camera, value, entry.entry_id) + for camera in coordinator.data + for capibility, value in coordinator.data[camera]["supportExt"].items() + if capibility == NUMBER_TYPE.supported_ext + if value in NUMBER_TYPE.supported_ext_value + ], + update_before_add=True, ) -class EzvizSensor(EzvizEntity, NumberEntity): +class EzvizSensor(EzvizBaseEntity, NumberEntity): """Representation of a EZVIZ number entity.""" _attr_has_entity_name = True @@ -53,46 +86,57 @@ class EzvizSensor(EzvizEntity, NumberEntity): self, coordinator: EzvizDataUpdateCoordinator, serial: str, - sensor: str, - description: NumberEntityDescription, + value: str, + config_entry_id: str, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, serial) - self._sensor_name = sensor - self.battery_cam_type = bool( - self.data["device_category"] - == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value - ) - self._attr_unique_id = f"{serial}_{sensor}" - self._attr_native_max_value = 100 if self.battery_cam_type else 6 - self.entity_description = description + self.sensitivity_type = 3 if value == "3" else 0 + self._attr_native_max_value = 100 if value == "3" else 6 + self._attr_unique_id = f"{serial}_{NUMBER_TYPE.key}" + self.entity_description = NUMBER_TYPE + self.config_entry_id = config_entry_id + self.sensor_value: int | None = None @property def native_value(self) -> float | None: """Return the state of the entity.""" - try: - return float(self.data[self._sensor_name]) - except ValueError: - return None + if self.sensor_value is not None: + return float(self.sensor_value) + return None def set_native_value(self, value: float) -> None: """Set camera detection sensitivity.""" level = int(value) try: - if self.battery_cam_type: - self.coordinator.ezviz_client.detection_sensibility( - self._serial, - level, - 3, - ) - else: - self.coordinator.ezviz_client.detection_sensibility( - self._serial, - level, - 0, - ) + self.coordinator.ezviz_client.detection_sensibility( + self._serial, + level, + self.sensitivity_type, + ) except (HTTPError, PyEzvizError) as err: raise HomeAssistantError( f"Cannot set detection sensitivity level on {self.name}" ) from err + + self.sensor_value = level + + def update(self) -> None: + """Fetch data from EZVIZ.""" + _LOGGER.debug("Updating %s", self.name) + try: + self.sensor_value = self.coordinator.ezviz_client.get_detection_sensibility( + self._serial, + str(self.sensitivity_type), + ) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode): + _LOGGER.debug("Failed to login to EZVIZ API") + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry_id) + ) + return + + except (InvalidURL, HTTPError, PyEzvizError) as error: + raise HomeAssistantError(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 6f00568cf2b..5711aff2a4a 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -62,7 +62,7 @@ "issues": { "service_depreciation_detection_sensibility": { "title": "Ezviz Detection sensitivity service is being removed", - "description": "Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.8; Please adjust the automation or script that uses the service and select submit below to mark this issue as resolved." + "description": "Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.12; Please adjust the automation or script that uses the service and select submit below to mark this issue as resolved." } } } diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index d4bd5f2e419..920f970185b 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -28,7 +28,7 @@ CONDITION_TYPES = {"is_on", "is_off"} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -50,7 +50,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] @@ -63,6 +63,9 @@ def async_condition_from_config( hass: HomeAssistant, config: ConfigType ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + if config[CONF_TYPE] == "is_on": state = STATE_ON else: @@ -71,6 +74,6 @@ def async_condition_from_config( @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index cfc44029e23..52d5aca070a 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -1,19 +1,4 @@ # Describes the format for available fan services -set_speed: - name: Set speed - description: Set fan speed. - target: - entity: - domain: fan - fields: - speed: - name: Speed - description: Speed setting. - required: true - example: "low" - selector: - text: - set_preset_mode: name: Set preset mode description: Set preset mode for a fan device. @@ -53,12 +38,6 @@ turn_on: entity: domain: fan fields: - speed: - name: Speed - description: Speed setting. - example: "high" - selector: - text: percentage: name: Percentage description: Percentage speed setting. diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index f4b1cd0c1f5..a56056ade03 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -263,7 +263,7 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): return device.mode @property - def hvac_mode(self) -> HVACMode | str | None: + def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool, idle.""" fibaro_operation_mode = self.fibaro_op_mode if isinstance(fibaro_operation_mode, str): diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 9112351ce06..b7942056a2c 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -235,11 +235,10 @@ class FidoSensor(SensorEntity): if (sensor_type := self.entity_description.key) == "balance": if self.fido_data.data.get(sensor_type) is not None: self._attr_native_value = round(self.fido_data.data[sensor_type], 2) - else: - if self.fido_data.data.get(self._number, {}).get(sensor_type) is not None: - self._attr_native_value = round( - self.fido_data.data[self._number][sensor_type], 2 - ) + elif self.fido_data.data.get(self._number, {}).get(sensor_type) is not None: + self._attr_native_value = round( + self.fido_data.data[self._number][sensor_type], 2 + ) class FidoData: diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 6f7e31a1e67..0b5c39f3629 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -34,17 +34,17 @@ ICON = "mdi:file" SENSOR_TYPES = ( SensorEntityDescription( key="file", + translation_key="size", icon=ICON, - name="Size", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="bytes", + translation_key="size_bytes", entity_registry_enabled_default=False, icon=ICON, - name="Size bytes", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, @@ -52,9 +52,9 @@ SENSOR_TYPES = ( ), SensorEntityDescription( key="last_updated", + translation_key="last_updated", entity_registry_enabled_default=False, icon=ICON, - name="Last Updated", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/filesize/strings.json b/homeassistant/components/filesize/strings.json index 90c286e7088..3323c3411b2 100644 --- a/homeassistant/components/filesize/strings.json +++ b/homeassistant/components/filesize/strings.json @@ -15,5 +15,18 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, - "title": "Filesize" + "title": "Filesize", + "entity": { + "sensor": { + "size": { + "name": "Size" + }, + "size_bytes": { + "name": "Size in bytes" + }, + "last_updated": { + "name": "Last updated" + } + } + } } diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 479e59d9cdf..3b961054544 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections import namedtuple from datetime import timedelta -from functools import cached_property import logging from typing import Any @@ -11,6 +10,7 @@ from fints.client import FinTS3PinTanClient from fints.models import SEPAAccount import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index 9e4d5b123f5..27f2c4a4526 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -35,6 +35,9 @@ async def async_setup_entry( class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): """Representation of an FireServiceRota sensor.""" + _attr_has_entity_name = True + _attr_translation_key = "duty" + def __init__( self, coordinator: DataUpdateCoordinator, @@ -44,15 +47,10 @@ class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): """Initialize.""" super().__init__(coordinator) self._client = client - self._unique_id = f"{entry.unique_id}_Duty" + self._attr_unique_id = f"{entry.unique_id}_Duty" self._state: bool | None = None - @property - def name(self) -> str: - """Return the name of the sensor.""" - return "Duty" - @property def icon(self) -> str: """Return the icon to use in the frontend.""" @@ -61,11 +59,6 @@ class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): return "mdi:calendar-remove" - @property - def unique_id(self) -> str: - """Return the unique ID for this binary sensor.""" - return self._unique_id - @property def is_on(self) -> bool | None: """Return the state of the binary sensor.""" diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 20f8589d2a2..797e39e99cd 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -28,20 +28,17 @@ class IncidentsSensor(RestoreEntity, SensorEntity): """Representation of FireServiceRota incidents sensor.""" _attr_should_poll = False + _attr_has_entity_name = True + _attr_translation_key = "incidents" def __init__(self, client): """Initialize.""" self._client = client self._entry_id = self._client.entry_id - self._unique_id = f"{self._client.unique_id}_Incidents" + self._attr_unique_id = f"{self._client.unique_id}_Incidents" self._state = None self._state_attributes = {} - @property - def name(self) -> str: - """Return the name of the sensor.""" - return "Incidents" - @property def icon(self) -> str: """Return the icon to use in the frontend.""" @@ -58,11 +55,6 @@ class IncidentsSensor(RestoreEntity, SensorEntity): """Return the state of the sensor.""" return self._state - @property - def unique_id(self) -> str: - """Return the unique ID of the sensor.""" - return self._unique_id - @property def extra_state_attributes(self) -> dict[str, Any]: """Return available attributes for sensor.""" diff --git a/homeassistant/components/fireservicerota/strings.json b/homeassistant/components/fireservicerota/strings.json index 7c60b438264..7b4bd583b63 100644 --- a/homeassistant/components/fireservicerota/strings.json +++ b/homeassistant/components/fireservicerota/strings.json @@ -25,5 +25,22 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "entity": { + "binary_sensor": { + "duty": { + "name": "Duty" + } + }, + "sensor": { + "incidents": { + "name": "Incidents" + } + }, + "switch": { + "incident_response": { + "name": "Incident response" + } + } } } diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py index 49c6d577b30..7409b2e53b4 100644 --- a/homeassistant/components/fireservicerota/switch.py +++ b/homeassistant/components/fireservicerota/switch.py @@ -28,23 +28,20 @@ class ResponseSwitch(SwitchEntity): """Representation of an FireServiceRota switch.""" _attr_should_poll = False + _attr_has_entity_name = True + _attr_translation_key = "incident_response" def __init__(self, coordinator, client, entry): """Initialize.""" self._coordinator = coordinator self._client = client - self._unique_id = f"{entry.unique_id}_Response" + self._attr_unique_id = f"{entry.unique_id}_Response" self._entry_id = entry.entry_id self._state = None self._state_attributes = {} self._state_icon = None - @property - def name(self) -> str: - """Return the name of the switch.""" - return "Incident Response" - @property def icon(self) -> str: """Return the icon to use in the frontend.""" @@ -60,11 +57,6 @@ class ResponseSwitch(SwitchEntity): """Get the assumed state of the switch.""" return self._state - @property - def unique_id(self) -> str: - """Return the unique ID for this switch.""" - return self._unique_id - @property def available(self) -> bool: """Return if switch is available.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index c53c01c84a7..11946c42173 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -442,14 +442,13 @@ class FitbitSensor(SensorEntity): self._attr_native_value = f"{hours}:{minutes:02d} {setting}" else: self._attr_native_value = raw_state + elif self.is_metric: + self._attr_native_value = raw_state else: - if self.is_metric: + try: + self._attr_native_value = int(raw_state) + except TypeError: self._attr_native_value = raw_state - else: - try: - self._attr_native_value = int(raw_state) - except TypeError: - self._attr_native_value = raw_state if resource_type == "activities/heart": self._attr_native_value = ( diff --git a/homeassistant/components/fivem/__init__.py b/homeassistant/components/fivem/__init__.py index 7b0ae2e2758..93adda2b4fd 100644 --- a/homeassistant/components/fivem/__init__.py +++ b/homeassistant/components/fivem/__init__.py @@ -1,36 +1,19 @@ """The FiveM integration.""" from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import timedelta import logging -from typing import Any -from fivem import FiveM, FiveMServerOfflineError +from fivem import FiveMServerOfflineError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.entity import DeviceInfo, EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) from .const import ( - ATTR_PLAYERS_LIST, - ATTR_RESOURCES_LIST, DOMAIN, - MANUFACTURER, - NAME_PLAYERS_MAX, - NAME_PLAYERS_ONLINE, - NAME_RESOURCES, - NAME_STATUS, - SCAN_INTERVAL, ) +from .coordinator import FiveMDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -67,98 +50,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class FiveMDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Class to manage fetching FiveM data.""" - - def __init__( - self, hass: HomeAssistant, config_data: Mapping[str, Any], unique_id: str - ) -> None: - """Initialize server instance.""" - self.unique_id = unique_id - self.server = None - self.version = None - self.game_name: str | None = None - - self.host = config_data[CONF_HOST] - - self._fivem = FiveM(self.host, config_data[CONF_PORT]) - - update_interval = timedelta(seconds=SCAN_INTERVAL) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def initialize(self) -> None: - """Initialize the FiveM server.""" - info = await self._fivem.get_info_raw() - self.server = info["server"] - self.version = info["version"] - self.game_name = info["vars"]["gamename"] - - async def _async_update_data(self) -> dict[str, Any]: - """Get server data from 3rd party library and update properties.""" - try: - server = await self._fivem.get_server() - except FiveMServerOfflineError as err: - raise UpdateFailed from err - - players_list: list[str] = [] - for player in server.players: - players_list.append(player.name) - players_list.sort() - - resources_list = server.resources - resources_list.sort() - - return { - NAME_PLAYERS_ONLINE: len(players_list), - NAME_PLAYERS_MAX: server.max_players, - NAME_RESOURCES: len(resources_list), - NAME_STATUS: self.last_update_success, - ATTR_PLAYERS_LIST: players_list, - ATTR_RESOURCES_LIST: resources_list, - } - - -@dataclass -class FiveMEntityDescription(EntityDescription): - """Describes FiveM entity.""" - - extra_attrs: list[str] | None = None - - -class FiveMEntity(CoordinatorEntity[FiveMDataUpdateCoordinator]): - """Representation of a FiveM base entity.""" - - entity_description: FiveMEntityDescription - - def __init__( - self, - coordinator: FiveMDataUpdateCoordinator, - description: FiveMEntityDescription, - ) -> None: - """Initialize base entity.""" - super().__init__(coordinator) - self.entity_description = description - - self._attr_name = f"{self.coordinator.host} {description.name}" - self._attr_unique_id = f"{self.coordinator.unique_id}-{description.key}".lower() - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.coordinator.unique_id)}, - manufacturer=MANUFACTURER, - model=self.coordinator.server, - name=self.coordinator.host, - sw_version=self.coordinator.version, - ) - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return the extra attributes of the sensor.""" - if self.entity_description.extra_attrs is None: - return None - - return { - attr: self.coordinator.data[attr] - for attr in self.entity_description.extra_attrs - } diff --git a/homeassistant/components/fivem/binary_sensor.py b/homeassistant/components/fivem/binary_sensor.py index f3f253fe530..153732d2ce5 100644 --- a/homeassistant/components/fivem/binary_sensor.py +++ b/homeassistant/components/fivem/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FiveMEntity, FiveMEntityDescription from .const import DOMAIN, NAME_STATUS +from .entity import FiveMEntity, FiveMEntityDescription @dataclass @@ -24,7 +24,7 @@ class FiveMBinarySensorEntityDescription( BINARY_SENSORS: tuple[FiveMBinarySensorEntityDescription, ...] = ( FiveMBinarySensorEntityDescription( key=NAME_STATUS, - name=NAME_STATUS, + translation_key="status", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), ) diff --git a/homeassistant/components/fivem/coordinator.py b/homeassistant/components/fivem/coordinator.py new file mode 100644 index 00000000000..e7fa4c426db --- /dev/null +++ b/homeassistant/components/fivem/coordinator.py @@ -0,0 +1,81 @@ +"""The FiveM update coordinator.""" +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +import logging +from typing import Any + +from fivem import FiveM, FiveMServerOfflineError + +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + ATTR_PLAYERS_LIST, + ATTR_RESOURCES_LIST, + DOMAIN, + NAME_PLAYERS_MAX, + NAME_PLAYERS_ONLINE, + NAME_RESOURCES, + NAME_STATUS, + SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +class FiveMDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching FiveM data.""" + + def __init__( + self, hass: HomeAssistant, config_data: Mapping[str, Any], unique_id: str + ) -> None: + """Initialize server instance.""" + self.unique_id = unique_id + self.server = None + self.version = None + self.game_name: str | None = None + + self.host = config_data[CONF_HOST] + + self._fivem = FiveM(self.host, config_data[CONF_PORT]) + + update_interval = timedelta(seconds=SCAN_INTERVAL) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def initialize(self) -> None: + """Initialize the FiveM server.""" + info = await self._fivem.get_info_raw() + self.server = info["server"] + self.version = info["version"] + self.game_name = info["vars"]["gamename"] + + async def _async_update_data(self) -> dict[str, Any]: + """Get server data from 3rd party library and update properties.""" + try: + server = await self._fivem.get_server() + except FiveMServerOfflineError as err: + raise UpdateFailed from err + + players_list: list[str] = [] + for player in server.players: + players_list.append(player.name) + players_list.sort() + + resources_list = server.resources + resources_list.sort() + + return { + NAME_PLAYERS_ONLINE: len(players_list), + NAME_PLAYERS_MAX: server.max_players, + NAME_RESOURCES: len(resources_list), + NAME_STATUS: self.last_update_success, + ATTR_PLAYERS_LIST: players_list, + ATTR_RESOURCES_LIST: resources_list, + } diff --git a/homeassistant/components/fivem/entity.py b/homeassistant/components/fivem/entity.py new file mode 100644 index 00000000000..53c35716276 --- /dev/null +++ b/homeassistant/components/fivem/entity.py @@ -0,0 +1,64 @@ +"""The FiveM entity.""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) + +from .const import ( + DOMAIN, + MANUFACTURER, +) +from .coordinator import FiveMDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class FiveMEntityDescription(EntityDescription): + """Describes FiveM entity.""" + + extra_attrs: list[str] | None = None + + +class FiveMEntity(CoordinatorEntity[FiveMDataUpdateCoordinator]): + """Representation of a FiveM base entity.""" + + _attr_has_entity_name = True + + entity_description: FiveMEntityDescription + + def __init__( + self, + coordinator: FiveMDataUpdateCoordinator, + description: FiveMEntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = description + + self._attr_unique_id = f"{self.coordinator.unique_id}-{description.key}".lower() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer=MANUFACTURER, + model=self.coordinator.server, + name=self.coordinator.host, + sw_version=self.coordinator.version, + ) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the extra attributes of the sensor.""" + if self.entity_description.extra_attrs is None: + return None + + return { + attr: self.coordinator.data[attr] + for attr in self.entity_description.extra_attrs + } diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index 9afe5890162..1c4e4b77c45 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -7,7 +7,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import FiveMEntity, FiveMEntityDescription from .const import ( ATTR_PLAYERS_LIST, ATTR_RESOURCES_LIST, @@ -22,6 +21,7 @@ from .const import ( UNIT_PLAYERS_ONLINE, UNIT_RESOURCES, ) +from .entity import FiveMEntity, FiveMEntityDescription @dataclass @@ -32,20 +32,20 @@ class FiveMSensorEntityDescription(SensorEntityDescription, FiveMEntityDescripti SENSORS: tuple[FiveMSensorEntityDescription, ...] = ( FiveMSensorEntityDescription( key=NAME_PLAYERS_MAX, - name=NAME_PLAYERS_MAX, + translation_key="max_players", icon=ICON_PLAYERS_MAX, native_unit_of_measurement=UNIT_PLAYERS_MAX, ), FiveMSensorEntityDescription( key=NAME_PLAYERS_ONLINE, - name=NAME_PLAYERS_ONLINE, + translation_key="online_players", icon=ICON_PLAYERS_ONLINE, native_unit_of_measurement=UNIT_PLAYERS_ONLINE, extra_attrs=[ATTR_PLAYERS_LIST], ), FiveMSensorEntityDescription( key=NAME_RESOURCES, - name=NAME_RESOURCES, + translation_key="resources", icon=ICON_RESOURCES, native_unit_of_measurement=UNIT_RESOURCES, extra_attrs=[ATTR_RESOURCES_LIST], diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index 4378ef535bd..2ffb401f8c0 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -17,5 +17,23 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + }, + "sensor": { + "max_players": { + "name": "Players max" + }, + "online_players": { + "name": "Players online" + }, + "resources": { + "name": "Resources" + } + } } } diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 044d17662cf..e867e624e8a 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -1,28 +1,23 @@ """The Fjäråskupan integration.""" from __future__ import annotations -from collections.abc import AsyncIterator, Callable -from contextlib import asynccontextmanager +from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta import logging -from fjaraskupan import Device, State +from fjaraskupan import Device from homeassistant.components.bluetooth import ( BluetoothCallbackMatcher, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak, - async_address_present, - async_ble_device_from_address, async_rediscover_address, async_register_callback, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -30,14 +25,9 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DISPATCH_DETECTION, DOMAIN - - -class UnableToConnect(HomeAssistantError): - """Exception to indicate that we cannot connect to device.""" - +from .coordinator import FjaraskupanCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -50,81 +40,11 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -class Coordinator(DataUpdateCoordinator[State]): - """Update coordinator for each device.""" - - def __init__( - self, hass: HomeAssistant, device: Device, device_info: DeviceInfo - ) -> None: - """Initialize the coordinator.""" - self.device = device - self.device_info = device_info - self._refresh_was_scheduled = False - - super().__init__( - hass, _LOGGER, name="Fjäråskupan", update_interval=timedelta(seconds=120) - ) - - async def _async_refresh( - self, - log_failures: bool = True, - raise_on_auth_failed: bool = False, - scheduled: bool = False, - raise_on_entry_error: bool = False, - ) -> None: - self._refresh_was_scheduled = scheduled - await super()._async_refresh( - log_failures=log_failures, - raise_on_auth_failed=raise_on_auth_failed, - scheduled=scheduled, - raise_on_entry_error=raise_on_entry_error, - ) - - async def _async_update_data(self) -> State: - """Handle an explicit update request.""" - if self._refresh_was_scheduled: - if async_address_present(self.hass, self.device.address, False): - return self.device.state - raise UpdateFailed( - "No data received within schedule, and device is no longer present" - ) - - if ( - ble_device := async_ble_device_from_address( - self.hass, self.device.address, True - ) - ) is None: - raise UpdateFailed("No connectable path to device") - async with self.device.connect(ble_device) as device: - await device.update() - return self.device.state - - def detection_callback(self, service_info: BluetoothServiceInfoBleak) -> None: - """Handle a new announcement of data.""" - self.device.detection_callback(service_info.device, service_info.advertisement) - self.async_set_updated_data(self.device.state) - - @asynccontextmanager - async def async_connect_and_update(self) -> AsyncIterator[Device]: - """Provide an up to date device for use during connections.""" - if ( - ble_device := async_ble_device_from_address( - self.hass, self.device.address, True - ) - ) is None: - raise UnableToConnect("No connectable path to device") - - async with self.device.connect(ble_device) as device: - yield device - - self.async_set_updated_data(self.device.state) - - @dataclass class EntryState: """Store state of config entry.""" - coordinators: dict[str, Coordinator] + coordinators: dict[str, FjaraskupanCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -153,7 +73,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name="Fjäråskupan", ) - coordinator: Coordinator = Coordinator(hass, device, device_info) + coordinator: FjaraskupanCoordinator = FjaraskupanCoordinator( + hass, device, device_info + ) coordinator.detection_callback(service_info) state.coordinators[service_info.address] = coordinator @@ -183,7 +105,7 @@ def async_setup_entry_platform( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - constructor: Callable[[Coordinator], list[Entity]], + constructor: Callable[[FjaraskupanCoordinator], list[Entity]], ) -> None: """Set up a platform with added entities.""" @@ -195,7 +117,7 @@ def async_setup_entry_platform( ) @callback - def _detection(coordinator: Coordinator) -> None: + def _detection(coordinator: FjaraskupanCoordinator) -> None: async_add_entities(constructor(coordinator)) entry.async_on_unload( diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 0ea5c1669db..8b641013eb4 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -17,7 +17,8 @@ from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Coordinator, async_setup_entry_platform +from . import async_setup_entry_platform +from .coordinator import FjaraskupanCoordinator @dataclass @@ -30,13 +31,13 @@ class EntityDescription(BinarySensorEntityDescription): SENSORS = ( EntityDescription( key="grease-filter", - name="Grease filter", + translation_key="grease_filter", device_class=BinarySensorDeviceClass.PROBLEM, is_on=lambda state: state.grease_filter_full, ), EntityDescription( key="carbon-filter", - name="Carbon filter", + translation_key="carbon_filter", device_class=BinarySensorDeviceClass.PROBLEM, is_on=lambda state: state.carbon_filter_full, ), @@ -50,7 +51,7 @@ async def async_setup_entry( ) -> None: """Set up sensors dynamically through discovery.""" - def _constructor(coordinator: Coordinator) -> list[Entity]: + def _constructor(coordinator: FjaraskupanCoordinator) -> list[Entity]: return [ BinarySensor( coordinator, @@ -64,7 +65,7 @@ async def async_setup_entry( async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class BinarySensor(CoordinatorEntity[Coordinator], BinarySensorEntity): +class BinarySensor(CoordinatorEntity[FjaraskupanCoordinator], BinarySensorEntity): """Grease filter sensor.""" entity_description: EntityDescription @@ -72,7 +73,7 @@ class BinarySensor(CoordinatorEntity[Coordinator], BinarySensorEntity): def __init__( self, - coordinator: Coordinator, + coordinator: FjaraskupanCoordinator, device: Device, device_info: DeviceInfo, entity_description: EntityDescription, diff --git a/homeassistant/components/fjaraskupan/coordinator.py b/homeassistant/components/fjaraskupan/coordinator.py new file mode 100644 index 00000000000..16e8157b094 --- /dev/null +++ b/homeassistant/components/fjaraskupan/coordinator.py @@ -0,0 +1,95 @@ +"""The Fjäråskupan data update coordinator.""" +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import timedelta +import logging + +from fjaraskupan import Device, State + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_address_present, + async_ble_device_from_address, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +class UnableToConnect(HomeAssistantError): + """Exception to indicate that we cannot connect to device.""" + + +class FjaraskupanCoordinator(DataUpdateCoordinator[State]): + """Update coordinator for each device.""" + + def __init__( + self, hass: HomeAssistant, device: Device, device_info: DeviceInfo + ) -> None: + """Initialize the coordinator.""" + self.device = device + self.device_info = device_info + self._refresh_was_scheduled = False + + super().__init__( + hass, _LOGGER, name="Fjäråskupan", update_interval=timedelta(seconds=120) + ) + + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + self._refresh_was_scheduled = scheduled + await super()._async_refresh( + log_failures=log_failures, + raise_on_auth_failed=raise_on_auth_failed, + scheduled=scheduled, + raise_on_entry_error=raise_on_entry_error, + ) + + async def _async_update_data(self) -> State: + """Handle an explicit update request.""" + if self._refresh_was_scheduled: + if async_address_present(self.hass, self.device.address, False): + return self.device.state + raise UpdateFailed( + "No data received within schedule, and device is no longer present" + ) + + if ( + ble_device := async_ble_device_from_address( + self.hass, self.device.address, True + ) + ) is None: + raise UpdateFailed("No connectable path to device") + async with self.device.connect(ble_device) as device: + await device.update() + return self.device.state + + def detection_callback(self, service_info: BluetoothServiceInfoBleak) -> None: + """Handle a new announcement of data.""" + self.device.detection_callback(service_info.device, service_info.advertisement) + self.async_set_updated_data(self.device.state) + + @asynccontextmanager + async def async_connect_and_update(self) -> AsyncIterator[Device]: + """Provide an up-to-date device for use during connections.""" + if ( + ble_device := async_ble_device_from_address( + self.hass, self.device.address, True + ) + ) is None: + raise UnableToConnect("No connectable path to device") + + async with self.device.connect(ble_device) as device: + yield device + + self.async_set_updated_data(self.device.state) diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index c856a94fa07..e19a0965524 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -23,7 +23,8 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from . import Coordinator, async_setup_entry_platform +from . import async_setup_entry_platform +from .coordinator import FjaraskupanCoordinator ORDERED_NAMED_FAN_SPEEDS = ["1", "2", "3", "4", "5", "6", "7", "8"] @@ -54,21 +55,22 @@ async def async_setup_entry( ) -> None: """Set up sensors dynamically through discovery.""" - def _constructor(coordinator: Coordinator): + def _constructor(coordinator: FjaraskupanCoordinator): return [Fan(coordinator, coordinator.device_info)] async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class Fan(CoordinatorEntity[Coordinator], FanEntity): +class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity): """Fan entity.""" _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE _attr_has_entity_name = True + _attr_name = None def __init__( self, - coordinator: Coordinator, + coordinator: FjaraskupanCoordinator, device_info: DeviceInfo, ) -> None: """Init fan entity.""" diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py index b6028e017d4..f4aa8c5a2dc 100644 --- a/homeassistant/components/fjaraskupan/light.py +++ b/homeassistant/components/fjaraskupan/light.py @@ -12,7 +12,8 @@ from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Coordinator, async_setup_entry_platform +from . import async_setup_entry_platform +from .coordinator import FjaraskupanCoordinator async def async_setup_entry( @@ -22,20 +23,21 @@ async def async_setup_entry( ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" - def _constructor(coordinator: Coordinator) -> list[Entity]: + def _constructor(coordinator: FjaraskupanCoordinator) -> list[Entity]: return [Light(coordinator, coordinator.device_info)] async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class Light(CoordinatorEntity[Coordinator], LightEntity): +class Light(CoordinatorEntity[FjaraskupanCoordinator], LightEntity): """Light device.""" _attr_has_entity_name = True + _attr_name = None def __init__( self, - coordinator: Coordinator, + coordinator: FjaraskupanCoordinator, device_info: DeviceInfo, ) -> None: """Init light entity.""" @@ -50,9 +52,8 @@ class Light(CoordinatorEntity[Coordinator], LightEntity): async with self.coordinator.async_connect_and_update() as device: if ATTR_BRIGHTNESS in kwargs: await device.send_dim(int(kwargs[ATTR_BRIGHTNESS] * (100.0 / 255.0))) - else: - if not self.is_on: - await device.send_command(COMMAND_LIGHT_ON_OFF) + elif not self.is_on: + await device.send_command(COMMAND_LIGHT_ON_OFF) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py index a7f9226b57a..46c5f6db90b 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -9,7 +9,8 @@ from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Coordinator, async_setup_entry_platform +from . import async_setup_entry_platform +from .coordinator import FjaraskupanCoordinator async def async_setup_entry( @@ -19,7 +20,7 @@ async def async_setup_entry( ) -> None: """Set up number entities dynamically through discovery.""" - def _constructor(coordinator: Coordinator) -> list[Entity]: + def _constructor(coordinator: FjaraskupanCoordinator) -> list[Entity]: return [ PeriodicVentingTime(coordinator, coordinator.device_info), ] @@ -27,7 +28,7 @@ async def async_setup_entry( async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class PeriodicVentingTime(CoordinatorEntity[Coordinator], NumberEntity): +class PeriodicVentingTime(CoordinatorEntity[FjaraskupanCoordinator], NumberEntity): """Periodic Venting.""" _attr_has_entity_name = True @@ -37,17 +38,17 @@ class PeriodicVentingTime(CoordinatorEntity[Coordinator], NumberEntity): _attr_native_step: float = 1 _attr_entity_category = EntityCategory.CONFIG _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_translation_key = "periodic_venting" def __init__( self, - coordinator: Coordinator, + coordinator: FjaraskupanCoordinator, device_info: DeviceInfo, ) -> None: """Init number entities.""" super().__init__(coordinator) self._attr_unique_id = f"{coordinator.device.address}-periodic-venting" self._attr_device_info = device_info - self._attr_name = "Periodic venting" @property def native_value(self) -> float | None: diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py index e06790bf9ac..e9bf84e0ed0 100644 --- a/homeassistant/components/fjaraskupan/sensor.py +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -16,7 +16,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Coordinator, async_setup_entry_platform +from . import async_setup_entry_platform +from .coordinator import FjaraskupanCoordinator async def async_setup_entry( @@ -26,20 +27,25 @@ async def async_setup_entry( ) -> None: """Set up sensors dynamically through discovery.""" - def _constructor(coordinator: Coordinator) -> list[Entity]: + def _constructor(coordinator: FjaraskupanCoordinator) -> list[Entity]: return [RssiSensor(coordinator, coordinator.device, coordinator.device_info)] async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class RssiSensor(CoordinatorEntity[Coordinator], SensorEntity): +class RssiSensor(CoordinatorEntity[FjaraskupanCoordinator], SensorEntity): """Sensor device.""" _attr_has_entity_name = True + _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + _attr_entity_registry_enabled_default = False + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, - coordinator: Coordinator, + coordinator: FjaraskupanCoordinator, device: Device, device_info: DeviceInfo, ) -> None: @@ -47,12 +53,6 @@ class RssiSensor(CoordinatorEntity[Coordinator], SensorEntity): super().__init__(coordinator) self._attr_unique_id = f"{device.address}-signal-strength" self._attr_device_info = device_info - self._attr_name = "Signal strength" - self._attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT - self._attr_entity_registry_enabled_default = False - self._attr_entity_category = EntityCategory.DIAGNOSTIC @property def native_value(self) -> StateType: diff --git a/homeassistant/components/fjaraskupan/strings.json b/homeassistant/components/fjaraskupan/strings.json index c6d5edd02d4..d91cc47dea1 100644 --- a/homeassistant/components/fjaraskupan/strings.json +++ b/homeassistant/components/fjaraskupan/strings.json @@ -9,5 +9,20 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "binary_sensor": { + "grease_filter": { + "name": "Grease filter" + }, + "carbon_filter": { + "name": "Carbon filter" + } + }, + "number": { + "periodic_venting": { + "name": "Periodic venting" + } + } } } diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 18daaa9d4e2..2210f44bf7a 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -8,7 +8,7 @@ from pyflick import FlickAPI, FlickPrice from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_FRIENDLY_NAME, CURRENCY_CENT, UnitOfEnergy +from homeassistant.const import CURRENCY_CENT, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow @@ -19,8 +19,6 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=5) -FRIENDLY_NAME = "Flick Power Price" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -36,19 +34,13 @@ class FlickPricingSensor(SensorEntity): _attr_attribution = "Data provided by Flick Electric" _attr_native_unit_of_measurement = f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}" + _attr_translation_key = "power_price" + _attributes: dict[str, Any] = {} def __init__(self, api: FlickAPI) -> None: """Entity object for Flick Electric sensor.""" self._api: FlickAPI = api self._price: FlickPrice = None - self._attributes: dict[str, Any] = { - ATTR_FRIENDLY_NAME: FRIENDLY_NAME, - } - - @property - def name(self): - """Return the name of the sensor.""" - return FRIENDLY_NAME @property def native_value(self): diff --git a/homeassistant/components/flick_electric/strings.json b/homeassistant/components/flick_electric/strings.json index cb8382539b4..8b55bef939e 100644 --- a/homeassistant/components/flick_electric/strings.json +++ b/homeassistant/components/flick_electric/strings.json @@ -19,5 +19,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } + }, + "entity": { + "sensor": { + "power_price": { + "name": "Flick power price" + } + } } } diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index f02911e30d5..9eba3206720 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -15,7 +15,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME +from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -84,6 +84,7 @@ class FliprEntity(CoordinatorEntity): """Implements a common class elements representing the Flipr component.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, coordinator: DataUpdateCoordinator, description: EntityDescription @@ -98,7 +99,5 @@ class FliprEntity(CoordinatorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, flipr_id)}, manufacturer=MANUFACTURER, - name=NAME, + name=f"Flipr {flipr_id}", ) - - self._attr_name = f"Flipr {flipr_id} {description.name}" diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index 646e260bd60..76385167d38 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -16,12 +16,12 @@ from .const import DOMAIN BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="ph_status", - name="PH Status", + translation_key="ph_status", device_class=BinarySensorDeviceClass.PROBLEM, ), BinarySensorEntityDescription( key="chlorine_status", - name="Chlorine Status", + translation_key="chlorine_status", device_class=BinarySensorDeviceClass.PROBLEM, ), ) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 57044289110..078e581edda 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -18,39 +18,38 @@ from .const import DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="chlorine", - name="Chlorine", + translation_key="chlorine", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, icon="mdi:pool", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ph", - name="pH", + translation_key="ph", icon="mdi:pool", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temperature", - name="Water Temp", + translation_key="water_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="date_time", - name="Last Measured", + translation_key="last_measured", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key="red_ox", - name="Red OX", + translation_key="red_ox", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, icon="mdi:pool", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="battery", - name="Battery Level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json index 55feaa691f7..24557ff177b 100644 --- a/homeassistant/components/flipr/strings.json +++ b/homeassistant/components/flipr/strings.json @@ -26,5 +26,32 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "ph_status": { + "name": "pH status" + }, + "chlorine_status": { + "name": "Chlorine status" + } + }, + "sensor": { + "chlorine": { + "name": "Chlorine" + }, + "ph": { + "name": "pH" + }, + "water_temperature": { + "name": "Water temperature" + }, + "last_measured": { + "name": "Last measured" + }, + "red_ox": { + "name": "Red OX" + } + } } } diff --git a/homeassistant/components/flo/binary_sensor.py b/homeassistant/components/flo/binary_sensor.py index f5c051d1a70..d61f67cc623 100644 --- a/homeassistant/components/flo/binary_sensor.py +++ b/homeassistant/components/flo/binary_sensor.py @@ -45,10 +45,11 @@ class FloPendingAlertsBinarySensor(FloEntity, BinarySensorEntity): """Binary sensor that reports on if there are any pending system alerts.""" _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_translation_key = "pending_system_alerts" def __init__(self, device): """Initialize the pending alerts binary sensor.""" - super().__init__("pending_system_alerts", "Pending system alerts", device) + super().__init__("pending_system_alerts", device) @property def is_on(self): @@ -71,10 +72,11 @@ class FloWaterDetectedBinarySensor(FloEntity, BinarySensorEntity): """Binary sensor that reports if water is detected (for leak detectors).""" _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_translation_key = "water_detected" def __init__(self, device): """Initialize the pending alerts binary sensor.""" - super().__init__("water_detected", "Water detected", device) + super().__init__("water_detected", device) @property def is_on(self): diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index 39ad57f5c03..e9d02432598 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -20,12 +20,10 @@ class FloEntity(Entity): def __init__( self, entity_type: str, - name: str, device: FloDeviceDataUpdateCoordinator, **kwargs, ) -> None: """Init Flo entity.""" - self._attr_name = name self._attr_unique_id = f"{device.mac_address}_{entity_type}" self._device: FloDeviceDataUpdateCoordinator = device diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index 2c89123dac1..f0aca366cfb 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -22,14 +22,6 @@ from .entity import FloEntity WATER_ICON = "mdi:water" GAUGE_ICON = "mdi:gauge" -NAME_DAILY_USAGE = "Today's water usage" -NAME_CURRENT_SYSTEM_MODE = "Current system mode" -NAME_FLOW_RATE = "Water flow rate" -NAME_WATER_TEMPERATURE = "Water temperature" -NAME_AIR_TEMPERATURE = "Temperature" -NAME_WATER_PRESSURE = "Water pressure" -NAME_HUMIDITY = "Humidity" -NAME_BATTERY = "Battery" async def async_setup_entry( @@ -46,7 +38,7 @@ async def async_setup_entry( if device.device_type == "puck_oem": entities.extend( [ - FloTemperatureSensor(NAME_AIR_TEMPERATURE, device), + FloTemperatureSensor(device, False), FloHumiditySensor(device), FloBatterySensor(device), ] @@ -57,7 +49,7 @@ async def async_setup_entry( FloDailyUsageSensor(device), FloSystemModeSensor(device), FloCurrentFlowRateSensor(device), - FloTemperatureSensor(NAME_WATER_TEMPERATURE, device), + FloTemperatureSensor(device, True), FloPressureSensor(device), ] ) @@ -71,10 +63,11 @@ class FloDailyUsageSensor(FloEntity, SensorEntity): _attr_native_unit_of_measurement = UnitOfVolume.GALLONS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_device_class = SensorDeviceClass.WATER + _attr_translation_key = "daily_consumption" def __init__(self, device): """Initialize the daily water usage sensor.""" - super().__init__("daily_consumption", NAME_DAILY_USAGE, device) + super().__init__("daily_consumption", device) self._state: float = None @property @@ -88,9 +81,11 @@ class FloDailyUsageSensor(FloEntity, SensorEntity): class FloSystemModeSensor(FloEntity, SensorEntity): """Monitors the current Flo system mode.""" + _attr_translation_key = "current_system_mode" + def __init__(self, device): """Initialize the system mode sensor.""" - super().__init__("current_system_mode", NAME_CURRENT_SYSTEM_MODE, device) + super().__init__("current_system_mode", device) self._state: str = None @property @@ -107,10 +102,11 @@ class FloCurrentFlowRateSensor(FloEntity, SensorEntity): _attr_icon = GAUGE_ICON _attr_native_unit_of_measurement = "gpm" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_translation_key = "current_flow_rate" def __init__(self, device): """Initialize the flow rate sensor.""" - super().__init__("current_flow_rate", NAME_FLOW_RATE, device) + super().__init__("current_flow_rate", device) self._state: float = None @property @@ -128,9 +124,11 @@ class FloTemperatureSensor(FloEntity, SensorEntity): _attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - def __init__(self, name, device): + def __init__(self, device, is_water): """Initialize the temperature sensor.""" - super().__init__("temperature", name, device) + super().__init__("temperature", device) + if is_water: + self._attr_translation_key = "water_temperature" self._state: float = None @property @@ -150,7 +148,7 @@ class FloHumiditySensor(FloEntity, SensorEntity): def __init__(self, device): """Initialize the humidity sensor.""" - super().__init__("humidity", NAME_HUMIDITY, device) + super().__init__("humidity", device) self._state: float = None @property @@ -167,10 +165,11 @@ class FloPressureSensor(FloEntity, SensorEntity): _attr_device_class = SensorDeviceClass.PRESSURE _attr_native_unit_of_measurement = UnitOfPressure.PSI _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_translation_key = "water_pressure" def __init__(self, device): """Initialize the pressure sensor.""" - super().__init__("water_pressure", NAME_WATER_PRESSURE, device) + super().__init__("water_pressure", device) self._state: float = None @property @@ -190,7 +189,7 @@ class FloBatterySensor(FloEntity, SensorEntity): def __init__(self, device): """Initialize the battery sensor.""" - super().__init__("battery", NAME_BATTERY, device) + super().__init__("battery", device) self._state: float = None @property diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index d6e3212b4ea..fadfc304fce 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -17,5 +17,37 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "pending_system_alerts": { + "name": "Pending system alerts" + }, + "water_detected": { + "name": "Water detected" + } + }, + "sensor": { + "daily_consumption": { + "name": "Today's water usage" + }, + "current_system_mode": { + "name": "Current system mode" + }, + "current_flow_rate": { + "name": "Water flow rate" + }, + "water_temperature": { + "name": "Water temperature" + }, + "water_pressure": { + "name": "Water pressure" + } + }, + "switch": { + "shutoff_valve": { + "name": "Shutoff valve" + } + } } } diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 84c37bb4987..cd522ed177d 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -68,9 +68,11 @@ async def async_setup_entry( class FloSwitch(FloEntity, SwitchEntity): """Switch class for the Flo by Moen valve.""" + _attr_translation_key = "shutoff_valve" + def __init__(self, device: FloDeviceDataUpdateCoordinator) -> None: """Initialize the Flo switch.""" - super().__init__("shutoff_valve", "Shutoff valve", device) + super().__init__("shutoff_valve", device) self._state = self._device.last_known_valve_state == "open" @property diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index 0d7b7b5dd56..453e259bf46 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -34,8 +34,7 @@ from .entity import FlumeEntity from .util import get_valid_flume_devices BINARY_SENSOR_DESCRIPTION_CONNECTED = BinarySensorEntityDescription( - name="Connected", - key="connected", + key="connected", device_class=BinarySensorDeviceClass.CONNECTIVITY ) @@ -56,14 +55,14 @@ class FlumeBinarySensorEntityDescription( FLUME_BINARY_NOTIFICATION_SENSORS: tuple[FlumeBinarySensorEntityDescription, ...] = ( FlumeBinarySensorEntityDescription( key="leak", - name="Leak detected", + translation_key="leak", entity_category=EntityCategory.DIAGNOSTIC, event_rule=NOTIFICATION_LEAK_DETECTED, icon="mdi:pipe-leak", ), FlumeBinarySensorEntityDescription( key="flow", - name="High flow", + translation_key="flow", entity_category=EntityCategory.DIAGNOSTIC, event_rule=NOTIFICATION_HIGH_FLOW, icon="mdi:waves", diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index f3b2bacbafe..17a2b0b53be 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/flume/", "iot_class": "cloud_polling", "loggers": ["pyflume"], - "requirements": ["pyflume==0.6.5"] + "requirements": ["PyFlume==0.6.5"] } diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index b656f5e9715..fc08fee476c 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -1,5 +1,4 @@ """Sensor for displaying the number of result from Flume.""" -from numbers import Number from pyflume import FlumeData @@ -34,45 +33,52 @@ from .util import get_valid_flume_devices FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="current_interval", - name="Current", + translation_key="current_interval", + suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/m", ), SensorEntityDescription( key="month_to_date", - name="Current Month", + translation_key="month_to_date", + suggested_display_precision=2, native_unit_of_measurement=UnitOfVolume.GALLONS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="week_to_date", - name="Current Week", + translation_key="week_to_date", + suggested_display_precision=2, native_unit_of_measurement=UnitOfVolume.GALLONS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="today", - name="Current Day", + translation_key="today", + suggested_display_precision=2, native_unit_of_measurement=UnitOfVolume.GALLONS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="last_60_min", - name="60 Minutes", + translation_key="last_60_min", + suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/h", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="last_24_hrs", - name="24 Hours", + translation_key="last_24_hrs", + suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/d", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="last_30_days", - name="30 Days", + translation_key="last_30_days", + suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/mo", state_class=SensorStateClass.MEASUREMENT, ), @@ -139,8 +145,4 @@ class FlumeSensor(FlumeEntity[FlumeDeviceDataUpdateCoordinator], SensorEntity): if sensor_key not in self.coordinator.flume_device.values: return None - return _format_state_value(self.coordinator.flume_device.values[sensor_key]) - - -def _format_state_value(value): - return round(value, 1) if isinstance(value, Number) else None + return self.coordinator.flume_device.values[sensor_key] diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json index 080f10deee1..2c1a900c091 100644 --- a/homeassistant/components/flume/strings.json +++ b/homeassistant/components/flume/strings.json @@ -28,5 +28,38 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } + }, + "entity": { + "binary_sensor": { + "leak": { + "name": "Leak detected" + }, + "flow": { + "name": "High flow" + } + }, + "sensor": { + "current_interval": { + "name": "Current" + }, + "month_to_date": { + "name": "Current month" + }, + "week_to_date": { + "name": "Current week" + }, + "today": { + "name": "Current day" + }, + "last_60_min": { + "name": "60 minutes" + }, + "last_24_hrs": { + "name": "24 hours" + }, + "last_30_days": { + "name": "30 days" + } + } } } diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 94f50caa1a2..100d63d8bf7 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -14,7 +14,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -71,6 +75,8 @@ NAME_TO_WHITE_CHANNEL_TYPE: Final = { option.name.lower(): option for option in WhiteChannelType } +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + @callback def async_wifi_bulb_for_host( diff --git a/homeassistant/components/flux_led/button.py b/homeassistant/components/flux_led/button.py index ead25930c77..eb3a7341d4d 100644 --- a/homeassistant/components/flux_led/button.py +++ b/homeassistant/components/flux_led/button.py @@ -10,7 +10,7 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.const import CONF_NAME, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,10 +22,13 @@ _RESTART_KEY = "restart" _UNPAIR_REMOTES_KEY = "unpair_remotes" RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( - key=_RESTART_KEY, name="Restart", device_class=ButtonDeviceClass.RESTART + key=_RESTART_KEY, + device_class=ButtonDeviceClass.RESTART, ) UNPAIR_REMOTES_DESCRIPTION = ButtonEntityDescription( - key=_UNPAIR_REMOTES_KEY, name="Unpair Remotes", icon="mdi:remote-off" + key=_UNPAIR_REMOTES_KEY, + translation_key="unpair_remotes", + icon="mdi:remote-off", ) @@ -62,7 +65,6 @@ class FluxButton(FluxBaseEntity, ButtonEntity): """Initialize the button.""" self.entity_description = description super().__init__(device, entry) - self._attr_name = f"{entry.data.get(CONF_NAME, entry.title)} {description.name}" base_unique_id = entry.unique_id or entry.entry_id self._attr_unique_id = f"{base_unique_id}_{description.key}" diff --git a/homeassistant/components/flux_led/entity.py b/homeassistant/components/flux_led/entity.py index ef9038b1435..85600dd4dab 100644 --- a/homeassistant/components/flux_led/entity.py +++ b/homeassistant/components/flux_led/entity.py @@ -54,6 +54,7 @@ def _async_device_info( class FluxBaseEntity(Entity): """Representation of a Flux entity without a coordinator.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( @@ -70,18 +71,18 @@ class FluxBaseEntity(Entity): class FluxEntity(CoordinatorEntity[FluxLedUpdateCoordinator]): """Representation of a Flux entity with a coordinator.""" + _attr_has_entity_name = True + def __init__( self, coordinator: FluxLedUpdateCoordinator, base_unique_id: str, - name: str, key: str | None, ) -> None: """Initialize the light.""" super().__init__(coordinator) self._device: AIOWifiLedBulb = coordinator.device self._responding = True - self._attr_name = name if key: self._attr_unique_id = f"{base_unique_id}_{key}" else: diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 202f0f95e23..d880d517f1a 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -22,7 +22,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv @@ -177,7 +176,6 @@ async def async_setup_entry( FluxLight( coordinator, entry.unique_id or entry.entry_id, - entry.data.get(CONF_NAME, entry.title), list(custom_effect_colors), options.get(CONF_CUSTOM_EFFECT_SPEED_PCT, DEFAULT_EFFECT_SPEED), options.get(CONF_CUSTOM_EFFECT_TRANSITION, TRANSITION_GRADUAL), @@ -191,19 +189,20 @@ class FluxLight( ): """Representation of a Flux light.""" + _attr_name = None + _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT def __init__( self, coordinator: FluxLedUpdateCoordinator, base_unique_id: str, - name: str, custom_effect_colors: list[tuple[int, int, int]], custom_effect_speed_pct: int, custom_effect_transition: str, ) -> None: """Initialize the light.""" - super().__init__(coordinator, base_unique_id, name, None) + super().__init__(coordinator, base_unique_id, None) self._attr_min_mireds = color_temperature_kelvin_to_mired(self._device.max_temp) self._attr_max_mireds = color_temperature_kelvin_to_mired(self._device.min_temp) self._attr_supported_color_modes = _hass_color_modes(self._device) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index a6e8183bcdb..13f7ba36bcd 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -51,5 +51,5 @@ "iot_class": "local_push", "loggers": ["flux_led"], "quality_scale": "platinum", - "requirements": ["flux_led==0.28.37"] + "requirements": ["flux-led==0.28.37"] } diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py index a5324efe680..ac23fbe64b5 100644 --- a/homeassistant/components/flux_led/number.py +++ b/homeassistant/components/flux_led/number.py @@ -18,7 +18,7 @@ from flux_led.protocol import ( from homeassistant import config_entries from homeassistant.components.light import EFFECT_RANDOM from homeassistant.components.number import NumberEntity, NumberMode -from homeassistant.const import CONF_NAME, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.debounce import Debouncer @@ -50,7 +50,6 @@ async def async_setup_entry( | FluxMusicPixelsPerSegmentNumber | FluxMusicSegmentsNumber ] = [] - name = entry.data.get(CONF_NAME, entry.title) base_unique_id = entry.unique_id or entry.entry_id if device.pixels_per_segment is not None: @@ -58,35 +57,25 @@ async def async_setup_entry( FluxPixelsPerSegmentNumber( coordinator, base_unique_id, - f"{name} Pixels Per Segment", "pixels_per_segment", ) ) if device.segments is not None: - entities.append( - FluxSegmentsNumber( - coordinator, base_unique_id, f"{name} Segments", "segments" - ) - ) + entities.append(FluxSegmentsNumber(coordinator, base_unique_id, "segments")) if device.music_pixels_per_segment is not None: entities.append( FluxMusicPixelsPerSegmentNumber( coordinator, base_unique_id, - f"{name} Music Pixels Per Segment", "music_pixels_per_segment", ) ) if device.music_segments is not None: entities.append( - FluxMusicSegmentsNumber( - coordinator, base_unique_id, f"{name} Music Segments", "music_segments" - ) + FluxMusicSegmentsNumber(coordinator, base_unique_id, "music_segments") ) if device.effect_list and device.effect_list != [EFFECT_RANDOM]: - entities.append( - FluxSpeedNumber(coordinator, base_unique_id, f"{name} Effect Speed", None) - ) + entities.append(FluxSpeedNumber(coordinator, base_unique_id, None)) async_add_entities(entities) @@ -101,6 +90,7 @@ class FluxSpeedNumber( _attr_native_step = 1 _attr_mode = NumberMode.SLIDER _attr_icon = "mdi:speedometer" + _attr_translation_key = "effect_speed" @property def native_value(self) -> float: @@ -137,11 +127,10 @@ class FluxConfigNumber( self, coordinator: FluxLedUpdateCoordinator, base_unique_id: str, - name: str, key: str | None, ) -> None: """Initialize the flux number.""" - super().__init__(coordinator, base_unique_id, name, key) + super().__init__(coordinator, base_unique_id, key) self._debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None self._pending_value: int | None = None @@ -185,6 +174,7 @@ class FluxConfigNumber( class FluxPixelsPerSegmentNumber(FluxConfigNumber): """Defines a flux_led pixels per segment number.""" + _attr_translation_key = "pixels_per_segment" _attr_icon = "mdi:dots-grid" @property @@ -211,6 +201,7 @@ class FluxPixelsPerSegmentNumber(FluxConfigNumber): class FluxSegmentsNumber(FluxConfigNumber): """Defines a flux_led segments number.""" + _attr_translation_key = "segments" _attr_icon = "mdi:segment" @property @@ -245,6 +236,7 @@ class FluxMusicNumber(FluxConfigNumber): class FluxMusicPixelsPerSegmentNumber(FluxMusicNumber): """Defines a flux_led music pixels per segment number.""" + _attr_translation_key = "music_pixels_per_segment" _attr_icon = "mdi:dots-grid" @property @@ -273,6 +265,7 @@ class FluxMusicPixelsPerSegmentNumber(FluxMusicNumber): class FluxMusicSegmentsNumber(FluxMusicNumber): """Defines a flux_led music segments number.""" + _attr_translation_key = "music_segments" _attr_icon = "mdi:segment" @property diff --git a/homeassistant/components/flux_led/select.py b/homeassistant/components/flux_led/select.py index 2b23f695b15..cca86e5a216 100644 --- a/homeassistant/components/flux_led/select.py +++ b/homeassistant/components/flux_led/select.py @@ -52,30 +52,22 @@ async def async_setup_entry( | FluxRemoteConfigSelect | FluxWhiteChannelSelect ] = [] - name = entry.data.get(CONF_NAME, entry.title) + entry.data.get(CONF_NAME, entry.title) base_unique_id = entry.unique_id or entry.entry_id if device.device_type == DeviceType.Switch: entities.append(FluxPowerStateSelect(coordinator.device, entry)) if device.operating_modes: entities.append( - FluxOperatingModesSelect( - coordinator, base_unique_id, f"{name} Operating Mode", "operating_mode" - ) + FluxOperatingModesSelect(coordinator, base_unique_id, "operating_mode") ) if device.wirings and device.wiring is not None: - entities.append( - FluxWiringsSelect(coordinator, base_unique_id, f"{name} Wiring", "wiring") - ) + entities.append(FluxWiringsSelect(coordinator, base_unique_id, "wiring")) if device.ic_types: - entities.append( - FluxICTypeSelect(coordinator, base_unique_id, f"{name} IC Type", "ic_type") - ) + entities.append(FluxICTypeSelect(coordinator, base_unique_id, "ic_type")) if device.remote_config: entities.append( - FluxRemoteConfigSelect( - coordinator, base_unique_id, f"{name} Remote Config", "remote_config" - ) + FluxRemoteConfigSelect(coordinator, base_unique_id, "remote_config") ) if FLUX_COLOR_MODE_RGBW in device.color_modes: entities.append(FluxWhiteChannelSelect(coordinator.device, entry)) @@ -98,6 +90,7 @@ class FluxConfigSelect(FluxEntity, SelectEntity): class FluxPowerStateSelect(FluxConfigAtStartSelect, SelectEntity): """Representation of a Flux power restore state option.""" + _attr_translation_key = "power_restored" _attr_icon = "mdi:transmission-tower-off" _attr_options = list(NAME_TO_POWER_RESTORE_STATE) @@ -108,7 +101,6 @@ class FluxPowerStateSelect(FluxConfigAtStartSelect, SelectEntity): ) -> None: """Initialize the power state select.""" super().__init__(device, entry) - self._attr_name = f"{entry.data.get(CONF_NAME, entry.title)} Power Restored" base_unique_id = entry.unique_id or entry.entry_id self._attr_unique_id = f"{base_unique_id}_power_restored" self._async_set_current_option_from_device() @@ -134,6 +126,7 @@ class FluxICTypeSelect(FluxConfigSelect): """Representation of Flux ic type.""" _attr_icon = "mdi:chip" + _attr_translation_key = "ic_type" @property def options(self) -> list[str]: @@ -156,6 +149,7 @@ class FluxWiringsSelect(FluxConfigSelect): """Representation of Flux wirings.""" _attr_icon = "mdi:led-strip-variant" + _attr_translation_key = "wiring" @property def options(self) -> list[str]: @@ -176,6 +170,8 @@ class FluxWiringsSelect(FluxConfigSelect): class FluxOperatingModesSelect(FluxConfigSelect): """Representation of Flux operating modes.""" + _attr_translation_key = "operating_mode" + @property def options(self) -> list[str]: """Return the current operating mode.""" @@ -196,15 +192,16 @@ class FluxOperatingModesSelect(FluxConfigSelect): class FluxRemoteConfigSelect(FluxConfigSelect): """Representation of Flux remote config type.""" + _attr_translation_key = "remote_config" + def __init__( self, coordinator: FluxLedUpdateCoordinator, base_unique_id: str, - name: str, key: str, ) -> None: """Initialize the remote config type select.""" - super().__init__(coordinator, base_unique_id, name, key) + super().__init__(coordinator, base_unique_id, key) assert self._device.remote_config is not None self._name_to_state = { _human_readable_option(option.name): option for option in RemoteConfig @@ -226,6 +223,8 @@ class FluxRemoteConfigSelect(FluxConfigSelect): class FluxWhiteChannelSelect(FluxConfigAtStartSelect): """Representation of Flux white channel.""" + _attr_translation_key = "white_channel" + _attr_options = [_human_readable_option(option.name) for option in WhiteChannelType] def __init__( @@ -235,7 +234,6 @@ class FluxWhiteChannelSelect(FluxConfigAtStartSelect): ) -> None: """Initialize the white channel select.""" super().__init__(device, entry) - self._attr_name = f"{entry.data.get(CONF_NAME, entry.title)} White Channel" base_unique_id = entry.unique_id or entry.entry_id self._attr_unique_id = f"{base_unique_id}_white_channel" diff --git a/homeassistant/components/flux_led/sensor.py b/homeassistant/components/flux_led/sensor.py index 3cff6d017f0..9a19c629383 100644 --- a/homeassistant/components/flux_led/sensor.py +++ b/homeassistant/components/flux_led/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant import config_entries from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_NAME, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,7 +25,6 @@ async def async_setup_entry( FluxPairedRemotes( coordinator, entry.unique_id or entry.entry_id, - f"{entry.data.get(CONF_NAME, entry.title)} Paired Remotes", "paired_remotes", ) ] @@ -37,6 +36,7 @@ class FluxPairedRemotes(FluxEntity, SensorEntity): _attr_icon = "mdi:remote" _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_translation_key = "paired_remotes" @property def native_value(self) -> int: diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json index 09d9ed399ff..51edd207e95 100644 --- a/homeassistant/components/flux_led/strings.json +++ b/homeassistant/components/flux_led/strings.json @@ -32,5 +32,62 @@ } } } + }, + "entity": { + "button": { + "unpair_remotes": { + "name": "Unpair remotes" + } + }, + "number": { + "pixels_per_segment": { + "name": "Pixels per segment" + }, + "segments": { + "name": "Segments" + }, + "music_pixels_per_segment": { + "name": "Music pixels per segment" + }, + "music_segments": { + "name": "Music segments" + }, + "effect_speed": { + "name": "Effect speed" + } + }, + "select": { + "operating_mode": { + "name": "Operating mode" + }, + "wiring": { + "name": "Wiring" + }, + "ic_type": { + "name": "IC type" + }, + "remote_config": { + "name": "Remote config" + }, + "white_channel": { + "name": "White channel" + }, + "power_restored": { + "name": "Power restored" + } + }, + "sensor": { + "paired_remotes": { + "name": "Paired remotes" + } + }, + "switch": { + "remote_access": { + "name": "Remote access" + }, + "music": { + "name": "Music" + } + } } } diff --git a/homeassistant/components/flux_led/switch.py b/homeassistant/components/flux_led/switch.py index d89f0020ba3..58aee132216 100644 --- a/homeassistant/components/flux_led/switch.py +++ b/homeassistant/components/flux_led/switch.py @@ -9,7 +9,7 @@ from flux_led.const import MODE_MUSIC from homeassistant import config_entries from homeassistant.components.switch import SwitchEntity -from homeassistant.const import CONF_NAME, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -34,18 +34,15 @@ async def async_setup_entry( coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[FluxSwitch | FluxRemoteAccessSwitch | FluxMusicSwitch] = [] base_unique_id = entry.unique_id or entry.entry_id - name = entry.data.get(CONF_NAME, entry.title) if coordinator.device.device_type == DeviceType.Switch: - entities.append(FluxSwitch(coordinator, base_unique_id, name, None)) + entities.append(FluxSwitch(coordinator, base_unique_id, None)) if entry.data.get(CONF_REMOTE_ACCESS_HOST): entities.append(FluxRemoteAccessSwitch(coordinator.device, entry)) if coordinator.device.microphone: - entities.append( - FluxMusicSwitch(coordinator, base_unique_id, f"{name} Music", "music") - ) + entities.append(FluxMusicSwitch(coordinator, base_unique_id, "music")) async_add_entities(entities) @@ -55,6 +52,8 @@ class FluxSwitch( ): """Representation of a Flux switch.""" + _attr_name = None + async def _async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if not self.is_on: @@ -65,6 +64,7 @@ class FluxRemoteAccessSwitch(FluxBaseEntity, SwitchEntity): """Representation of a Flux remote access switch.""" _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "remote_access" def __init__( self, @@ -73,7 +73,6 @@ class FluxRemoteAccessSwitch(FluxBaseEntity, SwitchEntity): ) -> None: """Initialize the light.""" super().__init__(device, entry) - self._attr_name = f"{entry.data.get(CONF_NAME, entry.title)} Remote Access" base_unique_id = entry.unique_id or entry.entry_id self._attr_unique_id = f"{base_unique_id}_remote_access" @@ -113,6 +112,8 @@ class FluxRemoteAccessSwitch(FluxBaseEntity, SwitchEntity): class FluxMusicSwitch(FluxEntity, SwitchEntity): """Representation of a Flux music switch.""" + _attr_translation_key = "music" + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the microphone on.""" await self._async_ensure_device_on() diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json index a517f1fea6f..890cd95784c 100644 --- a/homeassistant/components/foobot/manifest.json +++ b/homeassistant/components/foobot/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/foobot", "iot_class": "cloud_polling", "loggers": ["foobot_async"], - "requirements": ["foobot_async==1.0.0"] + "requirements": ["foobot-async==1.0.0"] } diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index 0e47fa9701b..e566733413b 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -1,14 +1,8 @@ """Constants for the Forecast.Solar integration.""" from __future__ import annotations -from datetime import timedelta import logging -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import UnitOfEnergy, UnitOfPower - -from .models import ForecastSolarSensorEntityDescription - DOMAIN = "forecast_solar" LOGGER = logging.getLogger(__package__) @@ -17,99 +11,3 @@ CONF_AZIMUTH = "azimuth" CONF_MODULES_POWER = "modules power" CONF_DAMPING = "damping" CONF_INVERTER_SIZE = "inverter_size" - -SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( - ForecastSolarSensorEntityDescription( - key="energy_production_today", - name="Estimated energy production - today", - state=lambda estimate: estimate.energy_production_today, - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=1, - ), - ForecastSolarSensorEntityDescription( - key="energy_production_today_remaining", - name="Estimated energy production - remaining today", - state=lambda estimate: estimate.energy_production_today_remaining, - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=1, - ), - ForecastSolarSensorEntityDescription( - key="energy_production_tomorrow", - name="Estimated energy production - tomorrow", - state=lambda estimate: estimate.energy_production_tomorrow, - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=1, - ), - ForecastSolarSensorEntityDescription( - key="power_highest_peak_time_today", - name="Highest power peak time - today", - device_class=SensorDeviceClass.TIMESTAMP, - ), - ForecastSolarSensorEntityDescription( - key="power_highest_peak_time_tomorrow", - name="Highest power peak time - tomorrow", - device_class=SensorDeviceClass.TIMESTAMP, - ), - ForecastSolarSensorEntityDescription( - key="power_production_now", - name="Estimated power production - now", - device_class=SensorDeviceClass.POWER, - state=lambda estimate: estimate.power_production_now, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.WATT, - ), - ForecastSolarSensorEntityDescription( - key="power_production_next_hour", - state=lambda estimate: estimate.power_production_at_time( - estimate.now() + timedelta(hours=1) - ), - name="Estimated power production - next hour", - device_class=SensorDeviceClass.POWER, - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfPower.WATT, - ), - ForecastSolarSensorEntityDescription( - key="power_production_next_12hours", - state=lambda estimate: estimate.power_production_at_time( - estimate.now() + timedelta(hours=12) - ), - name="Estimated power production - next 12 hours", - device_class=SensorDeviceClass.POWER, - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfPower.WATT, - ), - ForecastSolarSensorEntityDescription( - key="power_production_next_24hours", - state=lambda estimate: estimate.power_production_at_time( - estimate.now() + timedelta(hours=24) - ), - name="Estimated power production - next 24 hours", - device_class=SensorDeviceClass.POWER, - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfPower.WATT, - ), - ForecastSolarSensorEntityDescription( - key="energy_current_hour", - name="Estimated energy production - this hour", - state=lambda estimate: estimate.energy_current_hour, - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=1, - ), - ForecastSolarSensorEntityDescription( - key="energy_next_hour", - state=lambda estimate: estimate.sum_energy_production(1), - name="Estimated energy production - next hour", - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index ac6a3f7c308..94b603e108c 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["forecast_solar==3.0.0"] + "requirements": ["forecast-solar==3.0.0"] } diff --git a/homeassistant/components/forecast_solar/models.py b/homeassistant/components/forecast_solar/models.py deleted file mode 100644 index af9b6125713..00000000000 --- a/homeassistant/components/forecast_solar/models.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Models for the Forecast.Solar integration.""" -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from forecast_solar.models import Estimate - -from homeassistant.components.sensor import SensorEntityDescription - - -@dataclass -class ForecastSolarSensorEntityDescription(SensorEntityDescription): - """Describes a Forecast.Solar Sensor.""" - - state: Callable[[Estimate], Any] | None = None diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 681e04f434f..2858bff098e 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -1,10 +1,22 @@ """Support for the Forecast.Solar sensor service.""" from __future__ import annotations -from datetime import datetime +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity +from forecast_solar.models import Estimate + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -12,9 +24,112 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SENSORS +from .const import DOMAIN from .coordinator import ForecastSolarDataUpdateCoordinator -from .models import ForecastSolarSensorEntityDescription + + +@dataclass +class ForecastSolarSensorEntityDescription(SensorEntityDescription): + """Describes a Forecast.Solar Sensor.""" + + state: Callable[[Estimate], Any] | None = None + + +SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( + ForecastSolarSensorEntityDescription( + key="energy_production_today", + name="Estimated energy production - today", + state=lambda estimate: estimate.energy_production_today, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + ForecastSolarSensorEntityDescription( + key="energy_production_today_remaining", + name="Estimated energy production - remaining today", + state=lambda estimate: estimate.energy_production_today_remaining, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + ForecastSolarSensorEntityDescription( + key="energy_production_tomorrow", + name="Estimated energy production - tomorrow", + state=lambda estimate: estimate.energy_production_tomorrow, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + ForecastSolarSensorEntityDescription( + key="power_highest_peak_time_today", + name="Highest power peak time - today", + device_class=SensorDeviceClass.TIMESTAMP, + ), + ForecastSolarSensorEntityDescription( + key="power_highest_peak_time_tomorrow", + name="Highest power peak time - tomorrow", + device_class=SensorDeviceClass.TIMESTAMP, + ), + ForecastSolarSensorEntityDescription( + key="power_production_now", + name="Estimated power production - now", + device_class=SensorDeviceClass.POWER, + state=lambda estimate: estimate.power_production_now, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + ), + ForecastSolarSensorEntityDescription( + key="power_production_next_hour", + state=lambda estimate: estimate.power_production_at_time( + estimate.now() + timedelta(hours=1) + ), + name="Estimated power production - next hour", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfPower.WATT, + ), + ForecastSolarSensorEntityDescription( + key="power_production_next_12hours", + state=lambda estimate: estimate.power_production_at_time( + estimate.now() + timedelta(hours=12) + ), + name="Estimated power production - next 12 hours", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfPower.WATT, + ), + ForecastSolarSensorEntityDescription( + key="power_production_next_24hours", + state=lambda estimate: estimate.power_production_at_time( + estimate.now() + timedelta(hours=24) + ), + name="Estimated power production - next 24 hours", + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfPower.WATT, + ), + ForecastSolarSensorEntityDescription( + key="energy_current_hour", + name="Estimated energy production - this hour", + state=lambda estimate: estimate.energy_current_hour, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), + ForecastSolarSensorEntityDescription( + key="energy_next_hour", + state=lambda estimate: estimate.sum_energy_production(1), + name="Estimated energy production - next hour", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + ), +) async def async_setup_entry( diff --git a/homeassistant/components/freedompro/__init__.py b/homeassistant/components/freedompro/__init__.py index 5e1f8e0b577..78871bc99bf 100644 --- a/homeassistant/components/freedompro/__init__.py +++ b/homeassistant/components/freedompro/__init__.py @@ -1,19 +1,15 @@ """Support for freedompro.""" from __future__ import annotations -from datetime import timedelta import logging -from typing import Any, Final - -from pyfreedompro import get_list, get_states +from typing import Final from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN +from .coordinator import FreedomproDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -58,39 +54,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) - - -class FreedomproDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): - """Class to manage fetching Freedompro data API.""" - - def __init__(self, hass, api_key): - """Initialize.""" - self._hass = hass - self._api_key = api_key - self._devices: list[dict[str, Any]] | None = None - - update_interval = timedelta(minutes=1) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self): - if self._devices is None: - result = await get_list( - aiohttp_client.async_get_clientsession(self._hass), self._api_key - ) - if result["state"]: - self._devices = result["devices"] - else: - raise UpdateFailed() - - result = await get_states( - aiohttp_client.async_get_clientsession(self._hass), self._api_key - ) - - for device in self._devices: - dev = next( - (dev for dev in result if dev["uid"] == device["uid"]), - None, - ) - if dev is not None and "state" in dev: - device["state"] = dev["state"] - return self._devices diff --git a/homeassistant/components/freedompro/binary_sensor.py b/homeassistant/components/freedompro/binary_sensor.py index c56d3cb2ad8..9f397d32899 100644 --- a/homeassistant/components/freedompro/binary_sensor.py +++ b/homeassistant/components/freedompro/binary_sensor.py @@ -11,8 +11,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FreedomproDataUpdateCoordinator from .const import DOMAIN +from .coordinator import FreedomproDataUpdateCoordinator DEVICE_CLASS_MAP = { "smokeSensor": BinarySensorDeviceClass.SMOKE, @@ -44,14 +44,16 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], BinarySensorEntity): - """Representation of an Freedompro binary_sensor.""" + """Representation of a Freedompro binary_sensor.""" + + _attr_has_entity_name = True + _attr_name = None def __init__( self, device: dict[str, Any], coordinator: FreedomproDataUpdateCoordinator ) -> None: """Initialize the Freedompro binary_sensor.""" super().__init__(coordinator) - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._type = device["type"] self._attr_device_info = DeviceInfo( @@ -60,7 +62,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], BinarySensorEnt }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index 0ec08f0fdd0..8a0a706c0d9 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -22,8 +22,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FreedomproDataUpdateCoordinator from .const import DOMAIN +from .coordinator import FreedomproDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -58,10 +58,16 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): - """Representation of an Freedompro climate.""" + """Representation of a Freedompro climate.""" + _attr_has_entity_name = True _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_name = None + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_current_temperature = 0 + _attr_target_temperature = 0 + _attr_hvac_mode = HVACMode.OFF def __init__( self, @@ -74,7 +80,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): super().__init__(coordinator) self._session = session self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._characteristics = device["characteristics"] self._attr_device_info = DeviceInfo( @@ -83,12 +88,8 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - self._attr_current_temperature = 0 - self._attr_target_temperature = 0 - self._attr_hvac_mode = HVACMode.OFF @callback def _handle_coordinator_update(self) -> None: @@ -121,8 +122,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): if hvac_mode not in SUPPORTED_HVAC_MODES: raise ValueError(f"Got unsupported hvac_mode {hvac_mode}") - payload = {} - payload["heatingCoolingState"] = HVAC_INVERT_MAP[hvac_mode] + payload = {"heatingCoolingState": HVAC_INVERT_MAP[hvac_mode]} await put_state( self._session, self._api_key, diff --git a/homeassistant/components/freedompro/coordinator.py b/homeassistant/components/freedompro/coordinator.py new file mode 100644 index 00000000000..c896f5ec203 --- /dev/null +++ b/homeassistant/components/freedompro/coordinator.py @@ -0,0 +1,51 @@ +"""Freedompro data update coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from pyfreedompro import get_list, get_states + +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FreedomproDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): + """Class to manage fetching Freedompro data API.""" + + def __init__(self, hass, api_key): + """Initialize.""" + self._hass = hass + self._api_key = api_key + self._devices: list[dict[str, Any]] | None = None + + update_interval = timedelta(minutes=1) + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + if self._devices is None: + result = await get_list( + aiohttp_client.async_get_clientsession(self._hass), self._api_key + ) + if result["state"]: + self._devices = result["devices"] + else: + raise UpdateFailed() + + result = await get_states( + aiohttp_client.async_get_clientsession(self._hass), self._api_key + ) + + for device in self._devices: + dev = next( + (dev for dev in result if dev["uid"] == device["uid"]), + None, + ) + if dev is not None and "state" in dev: + device["state"] = dev["state"] + return self._devices diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py index 3839415d31b..59e58d75c43 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -18,8 +18,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FreedomproDataUpdateCoordinator from .const import DOMAIN +from .coordinator import FreedomproDataUpdateCoordinator DEVICE_CLASS_MAP = { "windowCovering": CoverDeviceClass.BLIND, @@ -46,7 +46,17 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], CoverEntity): - """Representation of an Freedompro cover.""" + """Representation of a Freedompro cover.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_current_cover_position = 0 + _attr_is_closed = True + _attr_supported_features = ( + CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + ) def __init__( self, @@ -59,7 +69,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], CoverEntity): super().__init__(coordinator) self._session = aiohttp_client.async_get_clientsession(hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._attr_device_info = DeviceInfo( identifiers={ @@ -67,14 +76,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], CoverEntity): }, manufacturer="Freedompro", model=device["type"], - name=self.name, - ) - self._attr_current_cover_position = 0 - self._attr_is_closed = True - self._attr_supported_features = ( - CoverEntityFeature.CLOSE - | CoverEntityFeature.OPEN - | CoverEntityFeature.SET_POSITION + name=device["name"], ) self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index 036c6c91471..68149b65fd7 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -15,8 +15,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FreedomproDataUpdateCoordinator from .const import DOMAIN +from .coordinator import FreedomproDataUpdateCoordinator async def async_setup_entry( @@ -33,7 +33,12 @@ async def async_setup_entry( class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntity): - """Representation of an Freedompro fan.""" + """Representation of a Freedompro fan.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_is_on = False + _attr_percentage = 0 def __init__( self, @@ -46,7 +51,6 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit super().__init__(coordinator) self._session = aiohttp_client.async_get_clientsession(hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._characteristics = device["characteristics"] self._attr_device_info = DeviceInfo( @@ -55,10 +59,8 @@ class FreedomproFan(CoordinatorEntity[FreedomproDataUpdateCoordinator], FanEntit }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) - self._attr_is_on = False - self._attr_percentage = 0 if "rotationSpeed" in self._characteristics: self._attr_supported_features = FanEntityFeature.SET_SPEED diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py index 7dc573f9225..2a101d5c82a 100644 --- a/homeassistant/components/freedompro/light.py +++ b/homeassistant/components/freedompro/light.py @@ -20,8 +20,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FreedomproDataUpdateCoordinator from .const import DOMAIN +from .coordinator import FreedomproDataUpdateCoordinator async def async_setup_entry( @@ -38,7 +38,12 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LightEntity): - """Representation of an Freedompro light.""" + """Representation of a Freedompro light.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_is_on = False + _attr_brightness = 0 def __init__( self, @@ -51,16 +56,13 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LightEntity): super().__init__(coordinator) self._session = aiohttp_client.async_get_clientsession(hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device["uid"])}, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) - self._attr_is_on = False - self._attr_brightness = 0 color_mode = ColorMode.ONOFF if "hue" in device["characteristics"]: color_mode = ColorMode.HS diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py index d803354c255..e1e8ee44b2d 100644 --- a/homeassistant/components/freedompro/lock.py +++ b/homeassistant/components/freedompro/lock.py @@ -13,8 +13,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FreedomproDataUpdateCoordinator from .const import DOMAIN +from .coordinator import FreedomproDataUpdateCoordinator async def async_setup_entry( @@ -31,7 +31,10 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LockEntity): - """Representation of an Freedompro lock.""" + """Representation of a Freedompro lock.""" + + _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -45,7 +48,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LockEntity): self._hass = hass self._session = aiohttp_client.async_get_clientsession(self._hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._type = device["type"] self._characteristics = device["characteristics"] @@ -55,7 +57,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], LockEntity): }, manufacturer="Freedompro", model=self._type, - name=self.name, + name=device["name"], ) @callback diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py index 286a528013a..85d70c30956 100644 --- a/homeassistant/components/freedompro/sensor.py +++ b/homeassistant/components/freedompro/sensor.py @@ -13,8 +13,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FreedomproDataUpdateCoordinator from .const import DOMAIN +from .coordinator import FreedomproDataUpdateCoordinator DEVICE_CLASS_MAP = { "temperatureSensor": SensorDeviceClass.TEMPERATURE, @@ -52,14 +52,16 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SensorEntity): - """Representation of an Freedompro sensor.""" + """Representation of a Freedompro sensor.""" + + _attr_has_entity_name = True + _attr_name = None def __init__( self, device: dict[str, Any], coordinator: FreedomproDataUpdateCoordinator ) -> None: """Initialize the Freedompro sensor.""" super().__init__(coordinator) - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._type = device["type"] self._attr_device_info = DeviceInfo( @@ -68,7 +70,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SensorEntity): }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] self._attr_state_class = STATE_CLASS_MAP[device["type"]] diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py index 4a7ed80de1e..97f0a968cff 100644 --- a/homeassistant/components/freedompro/switch.py +++ b/homeassistant/components/freedompro/switch.py @@ -13,8 +13,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FreedomproDataUpdateCoordinator from .const import DOMAIN +from .coordinator import FreedomproDataUpdateCoordinator async def async_setup_entry( @@ -31,7 +31,10 @@ async def async_setup_entry( class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SwitchEntity): - """Representation of an Freedompro switch.""" + """Representation of a Freedompro switch.""" + + _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -44,7 +47,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SwitchEntity): super().__init__(coordinator) self._session = aiohttp_client.async_get_clientsession(hass) self._api_key = api_key - self._attr_name = device["name"] self._attr_unique_id = device["uid"] self._attr_device_info = DeviceInfo( identifiers={ @@ -52,7 +54,7 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], SwitchEntity): }, manufacturer="Freedompro", model=device["type"], - name=self.name, + name=device["name"], ) self._attr_is_on = False diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index f732e32b75a..d76279a0f14 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -46,7 +46,6 @@ BUTTONS: Final = [ ), FritzButtonDescription( key="reboot", - translation_key="reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_reboot(), diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index d43ba2eda62..1ce21081f9c 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -31,6 +31,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, + Platform.IMAGE, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index e32ee152796..d4ba53aa6a2 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -21,9 +21,6 @@ from .const import DATA_FRITZ, DOMAIN _LOGGER = logging.getLogger(__name__) -YAML_DEFAULT_HOST = "169.254.1.1" -YAML_DEFAULT_USERNAME = "admin" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py new file mode 100644 index 00000000000..597dd8ddb53 --- /dev/null +++ b/homeassistant/components/fritz/image.py @@ -0,0 +1,95 @@ +"""FRITZ image integration.""" + +from __future__ import annotations + +from io import BytesIO +import logging + +from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util, slugify + +from .common import AvmWrapper, FritzBoxBaseEntity +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up guest WiFi QR code for device.""" + avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + + guest_wifi_info = await hass.async_add_executor_job( + avm_wrapper.fritz_guest_wifi.get_info + ) + + if not guest_wifi_info.get("NewEnable"): + return + + async_add_entities( + [ + FritzGuestWifiQRImage( + hass, avm_wrapper, entry.title, guest_wifi_info["NewSSID"] + ) + ] + ) + + +class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity): + """Implementation of the FritzBox guest wifi QR code image entity.""" + + _attr_content_type = "image/png" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_has_entity_name = True + _attr_should_poll = True + + def __init__( + self, + hass: HomeAssistant, + avm_wrapper: AvmWrapper, + device_friendly_name: str, + ssid: str, + ) -> None: + """Initialize the image entity.""" + self._attr_name = ssid + self._attr_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code") + self._current_qr_bytes: bytes | None = None + super().__init__(avm_wrapper, device_friendly_name) + ImageEntity.__init__(self, hass) + + async def _fetch_image(self) -> bytes: + """Fetch the QR code from the Fritz!Box.""" + qr_stream: BytesIO = await self.hass.async_add_executor_job( + self._avm_wrapper.fritz_guest_wifi.get_wifi_qr_code, "png" + ) + qr_bytes = qr_stream.getvalue() + _LOGGER.debug("fetched %s bytes", len(qr_bytes)) + + return qr_bytes + + async def async_added_to_hass(self) -> None: + """Fetch and set initial data and state.""" + self._current_qr_bytes = await self._fetch_image() + self._attr_image_last_updated = dt_util.utcnow() + + async def async_update(self) -> None: + """Update the image entity data.""" + qr_bytes = await self._fetch_image() + + if self._current_qr_bytes != qr_bytes: + dt_now = dt_util.utcnow() + _LOGGER.debug("qr code has changed, reset image last updated property") + self._attr_image_last_updated = dt_now + self._current_qr_bytes = qr_bytes + self.async_write_ha_state() + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return self._current_qr_bytes diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index b117218e23d..54419d5ae3f 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/fritz", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection==1.12.0", "xmltodict==0.13.0"], + "requirements": ["fritzconnection[qr]==1.12.0", "xmltodict==0.13.0"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 45262d6f8ac..fcaa56424f1 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -61,9 +61,6 @@ "button": { "cleanup": { "name": "Cleanup" }, "firmware_update": { "name": "Firmware update" }, - "reboot": { - "name": "[%key:component::button::entity_component::restart::name%]" - }, "reconnect": { "name": "Reconnect" } }, "sensor": { diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index f87beb34079..dc56bc0473e 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -42,8 +42,8 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( key="alarm", translation_key="alarm", device_class=BinarySensorDeviceClass.WINDOW, - suitable=lambda device: device.has_alarm, # type: ignore[no-any-return] - is_on=lambda device: device.alert_state, # type: ignore[no-any-return] + suitable=lambda device: device.has_alarm, + is_on=lambda device: device.alert_state, ), FritzBinarySensorEntityDescription( key="lock", diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 31cdac47ec2..7c846789637 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -101,7 +101,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): await self.coordinator.async_refresh() @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return the current operation mode.""" if self.data.target_temperature in ( OFF_REPORT_SET_TEMPERATURE, diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 7922224e195..46fa1a26561 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -91,66 +91,59 @@ def value_scheduled_preset(device: FritzhomeDevice) -> str: SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( FritzSensorEntityDescription( key="temperature", - translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_temperature, - native_value=lambda device: device.temperature, # type: ignore[no-any-return] + native_value=lambda device: device.temperature, ), FritzSensorEntityDescription( key="humidity", - translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, suitable=lambda device: device.rel_humidity is not None, - native_value=lambda device: device.rel_humidity, # type: ignore[no-any-return] + native_value=lambda device: device.rel_humidity, ), FritzSensorEntityDescription( key="battery", - translation_key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.battery_level is not None, - native_value=lambda device: device.battery_level, # type: ignore[no-any-return] + native_value=lambda device: device.battery_level, ), FritzSensorEntityDescription( key="power_consumption", - translation_key="power_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + suitable=lambda device: device.has_powermeter, native_value=lambda device: round((device.power or 0.0) / 1000, 3), ), FritzSensorEntityDescription( key="voltage", - translation_key="voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + suitable=lambda device: device.has_powermeter, native_value=lambda device: round((device.voltage or 0.0) / 1000, 2), ), FritzSensorEntityDescription( key="electric_current", - translation_key="electric_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + suitable=lambda device: device.has_powermeter, native_value=lambda device: round((device.current or 0.0) / 1000, 3), ), FritzSensorEntityDescription( key="total_energy", - translation_key="total_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + suitable=lambda device: device.has_powermeter, native_value=lambda device: (device.energy or 0.0) / 1000, ), # Thermostat Sensors @@ -161,7 +154,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_comfort_temperature, - native_value=lambda device: device.comfort_temperature, # type: ignore[no-any-return] + native_value=lambda device: device.comfort_temperature, ), FritzSensorEntityDescription( key="eco_temperature", @@ -170,7 +163,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_eco_temperature, - native_value=lambda device: device.eco_temperature, # type: ignore[no-any-return] + native_value=lambda device: device.eco_temperature, ), FritzSensorEntityDescription( key="nextchange_temperature", @@ -179,7 +172,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_temperature, - native_value=lambda device: device.nextchange_temperature, # type: ignore[no-any-return] + native_value=lambda device: device.nextchange_temperature, ), FritzSensorEntityDescription( key="nextchange_time", diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 0b4becd6ff7..d5607aa3090 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -44,33 +44,12 @@ "lock": { "name": "Button lock on device" } }, "sensor": { - "battery": { - "name": "[%key:component::sensor::entity_component::battery::name%]" - }, "comfort_temperature": { "name": "Comfort temperature" }, "eco_temperature": { "name": "Eco temperature" }, - "electric_current": { - "name": "[%key:component::sensor::entity_component::current::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "nextchange_preset": { "name": "Next scheduled preset" }, "nextchange_temperature": { "name": "Next scheduled temperature" }, "nextchange_time": { "name": "Next scheduled change time" }, - "power_consumption": { - "name": "[%key:component::sensor::entity_component::power::name%]" - }, - "scheduled_preset": { "name": "Current scheduled preset" }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "total_energy": { - "name": "[%key:component::sensor::entity_component::energy::name%]" - }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - } + "scheduled_preset": { "name": "Current scheduled preset" } } } } diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index cde955caa1e..d445c12e4da 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection==1.12.0"] + "requirements": ["fritzconnection[qr]==1.12.0"] } diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 7120530c973..ecf3f81b380 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["pyfronius"], "quality_scale": "platinum", - "requirements": ["pyfronius==0.7.1"] + "requirements": ["PyFronius==0.7.1"] } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 00753021b4c..9f53aef8165 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==20230608.0"] + "requirements": ["home-assistant-frontend==20230705.0"] } diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index 217e73e4d1c..8b350433858 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -2,6 +2,7 @@ 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.helpers.typing import ConfigType from .const import DOMAIN @@ -17,6 +18,8 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Fully Kiosk Browser.""" diff --git a/homeassistant/components/fully_kiosk/binary_sensor.py b/homeassistant/components/fully_kiosk/binary_sensor.py index f7371f4caed..5eebf8a77ab 100644 --- a/homeassistant/components/fully_kiosk/binary_sensor.py +++ b/homeassistant/components/fully_kiosk/binary_sensor.py @@ -18,18 +18,18 @@ from .entity import FullyKioskEntity SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="kioskMode", - name="Kiosk mode", + translation_key="kiosk_mode", entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensorEntityDescription( key="plugged", - name="Plugged in", + translation_key="plugged_in", device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensorEntityDescription( key="isDeviceAdmin", - name="Device admin", + translation_key="device_admin", entity_category=EntityCategory.DIAGNOSTIC, ), ) diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index 65b44262c8b..9f4d60e9574 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -39,31 +39,31 @@ class FullyButtonEntityDescription( BUTTONS: tuple[FullyButtonEntityDescription, ...] = ( FullyButtonEntityDescription( key="restartApp", - name="Restart browser", + translation_key="restart_browser", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.restartApp(), ), FullyButtonEntityDescription( key="rebootDevice", - name="Reboot device", + translation_key="restart_device", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.rebootDevice(), ), FullyButtonEntityDescription( key="toForeground", - name="Bring to foreground", + translation_key="to_foreground", press_action=lambda fully: fully.toForeground(), ), FullyButtonEntityDescription( key="toBackground", - name="Send to background", + translation_key="to_background", press_action=lambda fully: fully.toBackground(), ), FullyButtonEntityDescription( key="loadStartUrl", - name="Load start URL", + translation_key="load_start_url", press_action=lambda fully: fully.loadStartUrl(), ), ) diff --git a/homeassistant/components/fully_kiosk/const.py b/homeassistant/components/fully_kiosk/const.py index b4fe90e01eb..3db33d21ef0 100644 --- a/homeassistant/components/fully_kiosk/const.py +++ b/homeassistant/components/fully_kiosk/const.py @@ -25,6 +25,9 @@ MEDIA_SUPPORT_FULLYKIOSK = ( SERVICE_LOAD_URL = "load_url" SERVICE_START_APPLICATION = "start_application" +SERVICE_SET_CONFIG = "set_config" ATTR_URL = "url" ATTR_APPLICATION = "application" +ATTR_KEY = "key" +ATTR_VALUE = "value" diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py index 0e4329a5917..d1f98c5afff 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -9,6 +9,18 @@ from .const import DOMAIN from .coordinator import FullyKioskDataUpdateCoordinator +def valid_global_mac_address(mac: str | None) -> bool: + """Check if a MAC address is valid, non-locally administered address.""" + if not isinstance(mac, str): + return False + try: + first_octet = int(mac.split(":")[0], 16) + # If the second least-significant bit is set, it's a locally administered address, should not be used as an ID + return not bool(first_octet & 0x2) + except ValueError: + return False + + class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entity): """Defines a Fully Kiosk Browser entity.""" @@ -25,7 +37,9 @@ class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entit sw_version=coordinator.data["appVersionName"], configuration_url=f"http://{coordinator.data['ip4']}:2323", ) - if "Mac" in coordinator.data and coordinator.data["Mac"]: + if "Mac" in coordinator.data and valid_global_mac_address( + coordinator.data["Mac"] + ): device_info["connections"] = { (CONNECTION_NETWORK_MAC, coordinator.data["Mac"]) } diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index 8c73d47dd74..0984d6a220f 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -33,6 +33,7 @@ async def async_setup_entry( class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): """Representation of a Fully Kiosk Browser media player entity.""" + _attr_name = None _attr_supported_features = MEDIA_SUPPORT_FULLYKIOSK _attr_assumed_state = True diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py index 11f5bd27452..298a58e2a11 100644 --- a/homeassistant/components/fully_kiosk/number.py +++ b/homeassistant/components/fully_kiosk/number.py @@ -16,7 +16,7 @@ from .entity import FullyKioskEntity ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( NumberEntityDescription( key="timeToScreensaverV2", - name="Screensaver timer", + translation_key="screensaver_time", native_max_value=9999, native_step=1, native_min_value=0, @@ -25,7 +25,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( ), NumberEntityDescription( key="screensaverBrightness", - name="Screensaver brightness", + translation_key="screensaver_brightness", native_max_value=255, native_step=1, native_min_value=0, @@ -33,7 +33,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( ), NumberEntityDescription( key="timeToScreenOffV2", - name="Screen off timer", + translation_key="screen_off_time", native_max_value=9999, native_step=1, native_min_value=0, @@ -42,7 +42,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( ), NumberEntityDescription( key="screenBrightness", - name="Screen brightness", + translation_key="screen_brightness", native_max_value=255, native_step=1, native_min_value=0, diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index eed14f24674..dd775e7d55a 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -51,7 +51,6 @@ class FullySensorEntityDescription(SensorEntityDescription): SENSORS: tuple[FullySensorEntityDescription, ...] = ( FullySensorEntityDescription( key="batteryLevel", - name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -59,23 +58,23 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( ), FullySensorEntityDescription( key="currentPage", - name="Current page", + translation_key="current_page", entity_category=EntityCategory.DIAGNOSTIC, state_fn=truncate_url, ), FullySensorEntityDescription( key="screenOrientation", - name="Screen orientation", + translation_key="screen_orientation", entity_category=EntityCategory.DIAGNOSTIC, ), FullySensorEntityDescription( key="foregroundApp", - name="Foreground app", + translation_key="foreground_app", entity_category=EntityCategory.DIAGNOSTIC, ), FullySensorEntityDescription( key="internalStorageFreeSpace", - name="Internal storage free space", + translation_key="internal_storage_free_space", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -84,7 +83,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( ), FullySensorEntityDescription( key="internalStorageTotalSpace", - name="Internal storage total space", + translation_key="internal_storage_total_space", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -93,7 +92,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( ), FullySensorEntityDescription( key="ramFreeMemory", - name="Free memory", + translation_key="ram_free_memory", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -102,7 +101,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( ), FullySensorEntityDescription( key="ramTotalMemory", - name="Total memory", + translation_key="ram_total_memory", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index b3c5886187a..5106fd2e06e 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -12,9 +12,12 @@ import homeassistant.helpers.device_registry as dr from .const import ( ATTR_APPLICATION, + ATTR_KEY, ATTR_URL, + ATTR_VALUE, DOMAIN, SERVICE_LOAD_URL, + SERVICE_SET_CONFIG, SERVICE_START_APPLICATION, ) from .coordinator import FullyKioskDataUpdateCoordinator @@ -62,6 +65,22 @@ async def async_setup_services(hass: HomeAssistant) -> None: for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.fully.startApplication(call.data[ATTR_APPLICATION]) + async def async_set_config(call: ServiceCall) -> None: + """Set a Fully Kiosk Browser config value on the device.""" + for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + # Fully API has different methods for setting string and bool values. + # check if call.data[ATTR_VALUE] is a bool + if isinstance(call.data[ATTR_VALUE], bool) or call.data[ + ATTR_VALUE + ].lower() in ("true", "false"): + await coordinator.fully.setConfigurationBool( + call.data[ATTR_KEY], call.data[ATTR_VALUE] + ) + else: + await coordinator.fully.setConfigurationString( + call.data[ATTR_KEY], call.data[ATTR_VALUE] + ) + # Register all the above services service_mapping = [ (async_load_url, SERVICE_LOAD_URL, ATTR_URL), @@ -81,3 +100,18 @@ async def async_setup_services(hass: HomeAssistant) -> None: ) ), ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_CONFIG, + async_set_config, + schema=vol.Schema( + vol.All( + { + vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_KEY): cv.string, + vol.Required(ATTR_VALUE): vol.Any(str, bool), + } + ) + ), + ) diff --git a/homeassistant/components/fully_kiosk/services.yaml b/homeassistant/components/fully_kiosk/services.yaml index 88178e35809..1f75e4a0347 100644 --- a/homeassistant/components/fully_kiosk/services.yaml +++ b/homeassistant/components/fully_kiosk/services.yaml @@ -13,6 +13,28 @@ load_url: selector: text: +set_config: + name: Set Configuration + description: Set a configuration parameter on Fully Kiosk Browser. + target: + device: + integration: fully_kiosk + fields: + key: + name: Key + description: Configuration parameter to set. + example: "motionSensitivity" + required: true + selector: + text: + value: + name: Value + description: Value for the configuration parameter. + example: "90" + required: true + selector: + text: + start_application: name: Start Application description: Start an application on the device running Fully Kiosk Browser. diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index a6442085683..c10b6162859 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -21,5 +21,89 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } + }, + "entity": { + "binary_sensor": { + "kiosk_mode": { + "name": "Kiosk mode" + }, + "plugged_in": { + "name": "Plugged in" + }, + "device_admin": { + "name": "Device admin" + } + }, + "button": { + "restart_browser": { + "name": "Restart browser" + }, + "restart_device": { + "name": "Restart device" + }, + "to_foreground": { + "name": "Bring to foreground" + }, + "to_background": { + "name": "Send to background" + }, + "load_start_url": { + "name": "Load start URL" + } + }, + "number": { + "screensaver_time": { + "name": "Screensaver timer" + }, + "screensaver_brightness": { + "name": "Screensaver brightness" + }, + "screen_off_time": { + "name": "Screen off timer" + }, + "screen_brightness": { + "name": "Screen brightness" + } + }, + "sensor": { + "current_page": { + "name": "Current page" + }, + "screen_orientation": { + "name": "Screen orientation" + }, + "foreground_app": { + "name": "Foreground app" + }, + "internal_storage_total_space": { + "name": "Internal storage total space" + }, + "internal_storage_free_space": { + "name": "Internal storage free space" + }, + "ram_free_memory": { + "name": "Free memory" + }, + "ram_total_memory": { + "name": "Total memory" + } + }, + "switch": { + "screensaver": { + "name": "Screensaver" + }, + "maintenance": { + "name": "Maintenance mode" + }, + "kiosk": { + "name": "Kiosk lock" + }, + "motion_detection": { + "name": "Motion detection" + }, + "screen_on": { + "name": "Screen" + } + } } } diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index 1bd2a10fd21..500e154abd8 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -37,14 +37,14 @@ class FullySwitchEntityDescription( SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( FullySwitchEntityDescription( key="screensaver", - name="Screensaver", + translation_key="screensaver", on_action=lambda fully: fully.startScreensaver(), off_action=lambda fully: fully.stopScreensaver(), is_on_fn=lambda data: data.get("isInScreensaver"), ), FullySwitchEntityDescription( key="maintenance", - name="Maintenance mode", + translation_key="maintenance", entity_category=EntityCategory.CONFIG, on_action=lambda fully: fully.enableLockedMode(), off_action=lambda fully: fully.disableLockedMode(), @@ -52,7 +52,7 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( ), FullySwitchEntityDescription( key="kiosk", - name="Kiosk lock", + translation_key="kiosk", entity_category=EntityCategory.CONFIG, on_action=lambda fully: fully.lockKiosk(), off_action=lambda fully: fully.unlockKiosk(), @@ -60,7 +60,7 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( ), FullySwitchEntityDescription( key="motion-detection", - name="Motion detection", + translation_key="motion_detection", entity_category=EntityCategory.CONFIG, on_action=lambda fully: fully.enableMotionDetection(), off_action=lambda fully: fully.disableMotionDetection(), @@ -68,7 +68,7 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( ), FullySwitchEntityDescription( key="screenOn", - name="Screen", + translation_key="screen_on", on_action=lambda fully: fully.screenOn(), off_action=lambda fully: fully.screenOff(), is_on_fn=lambda data: data.get("screenOn"), diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index 86904e3e9bc..b6fb3d8cee3 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_georss_gdacs", "aio_georss_client"], "quality_scale": "platinum", - "requirements": ["aio_georss_gdacs==0.8"] + "requirements": ["aio-georss-gdacs==0.8"] } diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index b039b32d73d..234795e9014 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -200,7 +200,7 @@ class GenericCamera(Camera): try: async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) response = await async_client.get( - url, auth=self._auth, timeout=GET_IMAGE_TIMEOUT + url, auth=self._auth, follow_redirects=True, timeout=GET_IMAGE_TIMEOUT ) response.raise_for_status() self._last_image = response.content diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 94a885a7c5d..34fc5713271 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -9,10 +9,10 @@ import io import logging from typing import Any -import PIL from aiohttp import web from async_timeout import timeout from httpx import HTTPStatusError, RequestError, TimeoutException +import PIL import voluptuous as vol import yarl @@ -426,24 +426,12 @@ class GenericOptionsFlowHandler(OptionsFlow): # is always jpeg still_format = "image/jpeg" data = { - CONF_AUTHENTICATION: user_input.get(CONF_AUTHENTICATION), - CONF_STREAM_SOURCE: user_input.get(CONF_STREAM_SOURCE), - CONF_PASSWORD: user_input.get(CONF_PASSWORD), - CONF_STILL_IMAGE_URL: user_input.get(CONF_STILL_IMAGE_URL), + CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get( + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False + ), + **user_input, CONF_CONTENT_TYPE: still_format or self.config_entry.options.get(CONF_CONTENT_TYPE), - CONF_USERNAME: user_input.get(CONF_USERNAME), - CONF_LIMIT_REFETCH_TO_URL_CHANGE: user_input[ - CONF_LIMIT_REFETCH_TO_URL_CHANGE - ], - CONF_FRAMERATE: user_input[CONF_FRAMERATE], - CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], - CONF_USE_WALLCLOCK_AS_TIMESTAMPS: user_input.get( - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, - self.config_entry.options.get( - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False - ), - ), } self.user_input = data # temporary preview for user to check the image diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index fc06155121b..134ce00ef70 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.0", "pillow==9.5.0"] + "requirements": ["ha-av==10.1.0", "Pillow==9.5.0"] } diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index a6e76330f29..01945f9e242 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -248,6 +248,11 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): """Return true if the hygrostat is on.""" return self._state + @property + def current_humidity(self): + """Return the measured humidity.""" + return self._cur_humidity + @property def target_humidity(self): """Return the humidity we try to reach.""" @@ -430,17 +435,14 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): elif time is not None: # The time argument is passed only in keep-alive case await self._async_device_turn_on() - else: - if ( - self._device_class == HumidifierDeviceClass.HUMIDIFIER and too_dry - ) or ( - self._device_class == HumidifierDeviceClass.DEHUMIDIFIER and too_wet - ): - _LOGGER.info("Turning on humidifier %s", self._switch_entity_id) - await self._async_device_turn_on() - elif time is not None: - # The time argument is passed only in keep-alive case - await self._async_device_turn_off() + elif ( + self._device_class == HumidifierDeviceClass.HUMIDIFIER and too_dry + ) or (self._device_class == HumidifierDeviceClass.DEHUMIDIFIER and too_wet): + _LOGGER.info("Turning on humidifier %s", self._switch_entity_id) + await self._async_device_turn_on() + elif time is not None: + # The time argument is passed only in keep-alive case + await self._async_device_turn_off() @property def _is_device_active(self): diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 22a3f98a9f0..e3eed8866c8 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -494,16 +494,15 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self.heater_entity_id, ) await self._async_heater_turn_on() - else: - if (self.ac_mode and too_hot) or (not self.ac_mode and too_cold): - _LOGGER.info("Turning on heater %s", self.heater_entity_id) - await self._async_heater_turn_on() - elif time is not None: - # The time argument is passed only in keep-alive case - _LOGGER.info( - "Keep-alive - Turning off heater %s", self.heater_entity_id - ) - await self._async_heater_turn_off() + elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold): + _LOGGER.info("Turning on heater %s", self.heater_entity_id) + await self._async_heater_turn_on() + elif time is not None: + # The time argument is passed only in keep-alive case + _LOGGER.info( + "Keep-alive - Turning off heater %s", self.heater_entity_id + ) + await self._async_heater_turn_off() @property def _is_device_active(self): diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index c2b32582cef..bafda44501b 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -66,17 +66,17 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): return "mdi:radiator" @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return GH_HVAC_TO_HA.get(self._zone.data["mode"], HVACMode.HEAT) @property - def hvac_modes(self) -> list[str]: + def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" return list(HA_HVAC_TO_GH) @property - def hvac_action(self) -> str | None: + def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" if "_state" in self._zone.data: # only for v3 API if self._zone.data["output"] == 1: diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index def8f77994e..b922d98f25e 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -83,7 +83,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index b02339eb20a..9f77f9b112e 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_generic_client"], - "requirements": ["aio_geojson_generic_client==0.3"] + "requirements": ["aio-geojson-generic-client==0.3"] } diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json index 3ed5418fa0f..bdf8f126680 100644 --- a/homeassistant/components/geo_rss_events/manifest.json +++ b/homeassistant/components/geo_rss_events/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/geo_rss_events", "iot_class": "cloud_polling", "loggers": ["georss_client", "georss_generic_client"], - "requirements": ["georss_generic_client==0.6"] + "requirements": ["georss-generic-client==0.6"] } diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index 134877d7509..d1be775e370 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -36,14 +36,14 @@ class GeocachingSensorEntityDescription( SENSORS: tuple[GeocachingSensorEntityDescription, ...] = ( GeocachingSensorEntityDescription( key="find_count", - name="Total finds", + translation_key="find_count", icon="mdi:notebook-edit-outline", native_unit_of_measurement="caches", value_fn=lambda status: status.user.find_count, ), GeocachingSensorEntityDescription( key="hide_count", - name="Total hides", + translation_key="hide_count", icon="mdi:eye-off-outline", native_unit_of_measurement="caches", entity_registry_visible_default=False, @@ -51,7 +51,7 @@ SENSORS: tuple[GeocachingSensorEntityDescription, ...] = ( ), GeocachingSensorEntityDescription( key="favorite_points", - name="Favorite points", + translation_key="favorite_points", icon="mdi:heart-outline", native_unit_of_measurement="points", entity_registry_visible_default=False, @@ -59,14 +59,14 @@ SENSORS: tuple[GeocachingSensorEntityDescription, ...] = ( ), GeocachingSensorEntityDescription( key="souvenir_count", - name="Total souvenirs", + translation_key="souvenir_count", icon="mdi:license", native_unit_of_measurement="souvenirs", value_fn=lambda status: status.user.souvenir_count, ), GeocachingSensorEntityDescription( key="awarded_favorite_points", - name="Awarded favorite points", + translation_key="awarded_favorite_points", icon="mdi:heart", native_unit_of_measurement="points", entity_registry_visible_default=False, diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json index 7c8547805d1..6dc2fe8ec1c 100644 --- a/homeassistant/components/geocaching/strings.json +++ b/homeassistant/components/geocaching/strings.json @@ -21,5 +21,24 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "entity": { + "sensor": { + "find_count": { + "name": "Total finds" + }, + "hide_count": { + "name": "Total hides" + }, + "favorite_points": { + "name": "Favorite points" + }, + "souvenir_count": { + "name": "Total souvenirs" + }, + "awarded_favorite_points": { + "name": "Awarded favorite points" + } + } } } diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 74ca6406782..9ed59b2bc97 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_quakes"], "quality_scale": "platinum", - "requirements": ["aio_geojson_geonetnz_quakes==0.15"] + "requirements": ["aio-geojson-geonetnz-quakes==0.15"] } diff --git a/homeassistant/components/geonetnz_volcano/manifest.json b/homeassistant/components/geonetnz_volcano/manifest.json index c6cffad477d..6e9503e0243 100644 --- a/homeassistant/components/geonetnz_volcano/manifest.json +++ b/homeassistant/components/geonetnz_volcano/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_volcano"], - "requirements": ["aio_geojson_geonetnz_volcano==0.8"] + "requirements": ["aio-geojson-geonetnz-volcano==0.8"] } diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index f078cc074e9..64119436230 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -91,7 +91,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - translation_key="no2", ), GiosSensorEntityDescription( key=ATTR_NO2, @@ -109,7 +108,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - translation_key="o3", ), GiosSensorEntityDescription( key=ATTR_O3, @@ -127,7 +125,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - translation_key="pm10", ), GiosSensorEntityDescription( key=ATTR_PM10, @@ -145,7 +142,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - translation_key="pm25", ), GiosSensorEntityDescription( key=ATTR_PM25, @@ -163,7 +159,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - translation_key="so2", ), GiosSensorEntityDescription( key=ATTR_SO2, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index 5387c043fc3..ee0f50ef40c 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -42,9 +42,6 @@ "co": { "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" }, - "no2": { - "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" - }, "no2_index": { "name": "Nitrogen dioxide index", "state": { @@ -56,9 +53,6 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, - "o3": { - "name": "[%key:component::sensor::entity_component::ozone::name%]" - }, "o3_index": { "name": "Ozone index", "state": { @@ -70,9 +64,6 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, - "pm10": { - "name": "[%key:component::sensor::entity_component::pm10::name%]" - }, "pm10_index": { "name": "PM10 index", "state": { @@ -84,9 +75,6 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, - "pm25": { - "name": "[%key:component::sensor::entity_component::pm25::name%]" - }, "pm25_index": { "name": "PM2.5 index", "state": { @@ -98,9 +86,6 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, - "so2": { - "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" - }, "so2_index": { "name": "Sulphur dioxide index", "state": { diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index af6e5e2ca4a..edcdd8c057b 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -47,7 +47,7 @@ class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescription SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( GitHubSensorEntityDescription( key="discussions_count", - name="Discussions", + translation_key="discussions_count", native_unit_of_measurement="Discussions", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -55,7 +55,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="stargazers_count", - name="Stars", + translation_key="stargazers_count", icon="mdi:star", native_unit_of_measurement="Stars", entity_category=EntityCategory.DIAGNOSTIC, @@ -64,7 +64,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="subscribers_count", - name="Watchers", + translation_key="subscribers_count", icon="mdi:glasses", native_unit_of_measurement="Watchers", entity_category=EntityCategory.DIAGNOSTIC, @@ -73,7 +73,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="forks_count", - name="Forks", + translation_key="forks_count", icon="mdi:source-fork", native_unit_of_measurement="Forks", entity_category=EntityCategory.DIAGNOSTIC, @@ -82,7 +82,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="issues_count", - name="Issues", + translation_key="issues_count", native_unit_of_measurement="Issues", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -90,7 +90,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="pulls_count", - name="Pull requests", + translation_key="pulls_count", native_unit_of_measurement="Pull Requests", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -98,7 +98,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="latest_commit", - name="Latest commit", + translation_key="latest_commit", value_fn=lambda data: data["default_branch_ref"]["commit"]["message"][:255], attr_fn=lambda data: { "sha": data["default_branch_ref"]["commit"]["sha"], @@ -107,7 +107,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="latest_discussion", - name="Latest discussion", + translation_key="latest_discussion", avabl_fn=lambda data: data["discussion"]["discussions"], value_fn=lambda data: data["discussion"]["discussions"][0]["title"][:255], attr_fn=lambda data: { @@ -117,7 +117,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="latest_release", - name="Latest release", + translation_key="latest_release", avabl_fn=lambda data: data["release"] is not None, value_fn=lambda data: data["release"]["name"][:255], attr_fn=lambda data: { @@ -127,7 +127,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="latest_issue", - name="Latest issue", + translation_key="latest_issue", avabl_fn=lambda data: data["issue"]["issues"], value_fn=lambda data: data["issue"]["issues"][0]["title"][:255], attr_fn=lambda data: { @@ -137,7 +137,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="latest_pull_request", - name="Latest pull request", + translation_key="latest_pull_request", avabl_fn=lambda data: data["pull_request"]["pull_requests"], value_fn=lambda data: data["pull_request"]["pull_requests"][0]["title"][:255], attr_fn=lambda data: { @@ -147,7 +147,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( ), GitHubSensorEntityDescription( key="latest_tag", - name="Latest tag", + translation_key="latest_tag", avabl_fn=lambda data: data["refs"]["tags"], value_fn=lambda data: data["refs"]["tags"][0]["name"][:255], attr_fn=lambda data: { diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json index fa981d3dcb5..7b7ae91b9fd 100644 --- a/homeassistant/components/github/strings.json +++ b/homeassistant/components/github/strings.json @@ -15,5 +15,45 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "could_not_register": "Could not register integration with GitHub" } + }, + "entity": { + "sensor": { + "discussions_count": { + "name": "Discussions" + }, + "stargazers_count": { + "name": "Stars" + }, + "subscribers_count": { + "name": "Watchers" + }, + "forks_count": { + "name": "Forks" + }, + "issues_count": { + "name": "Issues" + }, + "pulls_count": { + "name": "Pull requests" + }, + "latest_commit": { + "name": "Latest commit" + }, + "latest_discussion": { + "name": "Latest discussion" + }, + "latest_release": { + "name": "Latest release" + }, + "latest_issue": { + "name": "Latest issue" + }, + "latest_pull_request": { + "name": "Latest pull request" + }, + "latest_tag": { + "name": "Latest tag" + } + } } } diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index 767a27ffdfd..d90f7b8274c 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/glances", "iot_class": "local_polling", "loggers": ["glances_api"], - "requirements": ["glances_api==0.4.2"] + "requirements": ["glances-api==0.4.3"] } diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index ec2834d00d8..faebcf7e353 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -1,7 +1,7 @@ { "domain": "gogogate2", "name": "Gogogate2 and ismartgate", - "codeowners": ["@vangorra", "@bdraco"], + "codeowners": ["@vangorra"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 3a0315a5931..37f3a2c3edc 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -28,10 +28,10 @@ from .const import ( # noqa: F401 DEFAULT_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSED_DOMAINS, DOMAIN, + EVENT_QUERY_RECEIVED, # noqa: F401 SERVICE_REQUEST_SYNC, SOURCE_CLOUD, ) -from .const import EVENT_QUERY_RECEIVED # noqa: F401 from .http import GoogleAssistantView, GoogleConfig from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401, isort:skip diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 918cec046fb..6ec8ca5d747 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -170,6 +170,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.RECEIVER): TYPE_RECEIVER, (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.SPEAKER): TYPE_SPEAKER, (media_player.DOMAIN, media_player.MediaPlayerDeviceClass.TV): TYPE_TV, + (sensor.DOMAIN, sensor.SensorDeviceClass.AQI): TYPE_SENSOR, (sensor.DOMAIN, sensor.SensorDeviceClass.HUMIDITY): TYPE_SENSOR, (sensor.DOMAIN, sensor.SensorDeviceClass.TEMPERATURE): TYPE_SENSOR, (switch.DOMAIN, switch.SwitchDeviceClass.OUTLET): TYPE_OUTLET, diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 1b1b443baac..b8c57812540 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -278,7 +278,6 @@ async def async_devices_disconnect( """ assert data.context.user_id is not None await data.config.async_disconnect_agent_user(data.context.user_id) - return None @HANDLERS.register("action.devices.IDENTIFY") diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 6f7dbd2c3b0..36660820efb 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -922,9 +922,28 @@ class TemperatureSettingTrait(_Trait): def sync_attributes(self): """Return temperature point and modes attributes for a sync request.""" response = {} - response["thermostatTemperatureUnit"] = _google_temp_unit( - self.hass.config.units.temperature_unit + attrs = self.state.attributes + unit = self.hass.config.units.temperature_unit + response["thermostatTemperatureUnit"] = _google_temp_unit(unit) + + min_temp = round( + TemperatureConverter.convert( + float(attrs[climate.ATTR_MIN_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) ) + max_temp = round( + TemperatureConverter.convert( + float(attrs[climate.ATTR_MAX_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + response["thermostatTemperatureRange"] = { + "minThresholdCelsius": min_temp, + "maxThresholdCelsius": max_temp, + } modes = self.climate_google_modes @@ -989,24 +1008,22 @@ class TemperatureSettingTrait(_Trait): ), 1, ) - else: - if (target_temp := attrs.get(ATTR_TEMPERATURE)) is not None: - target_temp = round( - TemperatureConverter.convert( - target_temp, unit, UnitOfTemperature.CELSIUS - ), - 1, - ) - response["thermostatTemperatureSetpointHigh"] = target_temp - response["thermostatTemperatureSetpointLow"] = target_temp - else: - if (target_temp := attrs.get(ATTR_TEMPERATURE)) is not None: - response["thermostatTemperatureSetpoint"] = round( + elif (target_temp := attrs.get(ATTR_TEMPERATURE)) is not None: + target_temp = round( TemperatureConverter.convert( target_temp, unit, UnitOfTemperature.CELSIUS ), 1, ) + response["thermostatTemperatureSetpointHigh"] = target_temp + response["thermostatTemperatureSetpointLow"] = target_temp + elif (target_temp := attrs.get(ATTR_TEMPERATURE)) is not None: + response["thermostatTemperatureSetpoint"] = round( + TemperatureConverter.convert( + target_temp, unit, UnitOfTemperature.CELSIUS + ), + 1, + ) return response @@ -1197,9 +1214,12 @@ class HumiditySettingTrait(_Trait): response["humidityAmbientPercent"] = round(float(current_humidity)) elif domain == humidifier.DOMAIN: - target_humidity = attrs.get(humidifier.ATTR_HUMIDITY) + target_humidity: int | None = attrs.get(humidifier.ATTR_HUMIDITY) if target_humidity is not None: - response["humiditySetpointPercent"] = round(float(target_humidity)) + response["humiditySetpointPercent"] = target_humidity + current_humidity: int | None = attrs.get(humidifier.ATTR_CURRENT_HUMIDITY) + if current_humidity is not None: + response["humidityAmbientPercent"] = current_humidity return response @@ -2393,6 +2413,23 @@ class SensorStateTrait(_Trait): name = TRAIT_SENSOR_STATE commands: list[str] = [] + def _air_quality_description_for_aqi(self, aqi): + if aqi is None or aqi.isnumeric() is False: + return "unknown" + aqi = int(aqi) + if aqi <= 50: + return "healthy" + if aqi <= 100: + return "moderate" + if aqi <= 150: + return "unhealthy for sensitive groups" + if aqi <= 200: + return "unhealthy" + if aqi <= 300: + return "very unhealthy" + + return "hazardous" + @classmethod def supported(cls, domain, features, device_class, _): """Test if state is supported.""" @@ -2401,20 +2438,44 @@ class SensorStateTrait(_Trait): def sync_attributes(self): """Return attributes for a sync request.""" device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) - if (data := self.sensor_types.get(device_class)) is not None: - return { - "sensorStatesSupported": { - "name": data[0], - "numericCapabilities": {"rawValueUnit": data[1]}, - } + data = self.sensor_types.get(device_class) + + if device_class is None or data is None: + return {} + + sensor_state = { + "name": data[0], + "numericCapabilities": {"rawValueUnit": data[1]}, + } + + if device_class == sensor.SensorDeviceClass.AQI: + sensor_state["descriptiveCapabilities"] = { + "availableStates": [ + "healthy", + "moderate", + "unhealthy for sensitive groups", + "unhealthy", + "very unhealthy", + "hazardous", + "unknown", + ], } + return {"sensorStatesSupported": [sensor_state]} + def query_attributes(self): """Return the attributes of this trait for this entity.""" device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) - if (data := self.sensor_types.get(device_class)) is not None: - return { - "currentSensorStateData": [ - {"name": data[0], "rawValue": self.state.state} - ] - } + data = self.sensor_types.get(device_class) + + if device_class is None or data is None: + return {} + + sensor_data = {"name": data[0], "rawValue": self.state.state} + + if device_class == sensor.SensorDeviceClass.AQI: + sensor_data["currentSensorState"] = self._air_quality_description_for_aqi( + self.state.state + ) + + return {"currentSensorStateData": [sensor_data]} diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 7a9ca70bf14..db2a8d9512e 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -18,18 +18,11 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_ENABLE_CONVERSATION_AGENT, - CONF_LANGUAGE_CODE, - DATA_MEM_STORAGE, - DATA_SESSION, - DOMAIN, -) +from .const import DATA_MEM_STORAGE, DATA_SESSION, DOMAIN, SUPPORTED_LANGUAGE_CODES from .helpers import ( GoogleAssistantSDKAudioView, InMemoryStorage, async_send_text_commands, - default_language_code, ) SERVICE_SEND_TEXT_COMMAND = "send_text_command" @@ -44,6 +37,8 @@ SERVICE_SEND_TEXT_COMMAND_SCHEMA = vol.All( }, ) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Google Assistant SDK component.""" @@ -80,8 +75,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_setup_service(hass) - entry.async_on_unload(entry.add_update_listener(update_listener)) - await update_listener(hass, entry) + agent = GoogleAssistantConversationAgent(hass, entry) + conversation.async_set_agent(hass, entry, agent) return True @@ -98,8 +93,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for service_name in hass.services.async_services()[DOMAIN]: hass.services.async_remove(DOMAIN, service_name) - if entry.options.get(CONF_ENABLE_CONVERSATION_AGENT, False): - conversation.async_unset_agent(hass, entry) + conversation.async_unset_agent(hass, entry) return True @@ -123,15 +117,6 @@ async def async_setup_service(hass: HomeAssistant) -> None: ) -async def update_listener(hass, entry): - """Handle options update.""" - if entry.options.get(CONF_ENABLE_CONVERSATION_AGENT, False): - agent = GoogleAssistantConversationAgent(hass, entry) - conversation.async_set_agent(hass, entry, agent) - else: - conversation.async_unset_agent(hass, entry) - - class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): """Google Assistant SDK conversation agent.""" @@ -141,6 +126,7 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): self.entry = entry self.assistant: TextAssistant | None = None self.session: OAuth2Session | None = None + self.language: str | None = None @property def attribution(self): @@ -153,10 +139,7 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - language_code = self.entry.options.get( - CONF_LANGUAGE_CODE, default_language_code(self.hass) - ) - return [language_code] + return SUPPORTED_LANGUAGE_CODES async def async_process( self, user_input: conversation.ConversationInput @@ -170,12 +153,10 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): if not session.valid_token: await session.async_ensure_token_valid() self.assistant = None - if not self.assistant: + if not self.assistant or user_input.language != self.language: credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) - language_code = self.entry.options.get( - CONF_LANGUAGE_CODE, default_language_code(self.hass) - ) - self.assistant = TextAssistant(credentials, language_code) + self.language = user_input.language + self.assistant = TextAssistant(credentials, self.language) resp = self.assistant.assist(user_input.text) text_response = resp[0] or "" diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index b93a3be93f2..b4f617ca029 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -13,13 +13,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow -from .const import ( - CONF_ENABLE_CONVERSATION_AGENT, - CONF_LANGUAGE_CODE, - DEFAULT_NAME, - DOMAIN, - SUPPORTED_LANGUAGE_CODES, -) +from .const import CONF_LANGUAGE_CODE, DEFAULT_NAME, DOMAIN, SUPPORTED_LANGUAGE_CODES from .helpers import default_language_code _LOGGER = logging.getLogger(__name__) @@ -114,12 +108,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_LANGUAGE_CODE, default=self.config_entry.options.get(CONF_LANGUAGE_CODE), ): vol.In(SUPPORTED_LANGUAGE_CODES), - vol.Required( - CONF_ENABLE_CONVERSATION_AGENT, - default=self.config_entry.options.get( - CONF_ENABLE_CONVERSATION_AGENT - ), - ): bool, } ), ) diff --git a/homeassistant/components/google_assistant_sdk/const.py b/homeassistant/components/google_assistant_sdk/const.py index c9f86160bb4..d63aec0ebd5 100644 --- a/homeassistant/components/google_assistant_sdk/const.py +++ b/homeassistant/components/google_assistant_sdk/const.py @@ -5,7 +5,6 @@ DOMAIN: Final = "google_assistant_sdk" DEFAULT_NAME: Final = "Google Assistant SDK" -CONF_ENABLE_CONVERSATION_AGENT: Final = "enable_conversation_agent" CONF_LANGUAGE_CODE: Final = "language_code" DATA_MEM_STORAGE: Final = "mem_storage" diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index d4c85be91e5..66a2b975b5e 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -31,10 +31,8 @@ "step": { "init": { "data": { - "enable_conversation_agent": "Enable the conversation agent", "language_code": "Language code" - }, - "description": "Set language for interactions with Google Assistant and whether you want to enable the conversation agent." + } } } }, diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 52de9215535..65d9e0b3894 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-generativeai==0.1.0rc2"] + "requirements": ["google-generativeai==0.1.0"] } diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index a24d5c17874..15c4192ccf5 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -7,8 +7,7 @@ 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 discovery -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -21,6 +20,8 @@ from .services import async_setup_services PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Google Mail platform.""" @@ -33,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Mail from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) + auth = AsyncConfigEntryAuth(session) try: await auth.check_and_refresh_token() except ClientResponseError as err: diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index 202fa5b56b6..ffa33deae14 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -1,25 +1,21 @@ """API for Google Mail bound to Home Assistant OAuth.""" -from aiohttp import ClientSession from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials -from google.oauth2.utils import OAuthClientAuthHandler from googleapiclient.discovery import Resource, build from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers import config_entry_oauth2_flow -class AsyncConfigEntryAuth(OAuthClientAuthHandler): +class AsyncConfigEntryAuth: """Provide Google Mail authentication tied to an OAuth2 based config entry.""" def __init__( self, - websession: ClientSession, - oauth2Session: config_entry_oauth2_flow.OAuth2Session, + oauth2_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Google Mail Auth.""" - self.oauth_session = oauth2Session - super().__init__(websession) + self.oauth_session = oauth2_session @property def access_token(self) -> str: diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index 8023b9222a0..a65e845095c 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -21,7 +21,7 @@ SCAN_INTERVAL = timedelta(minutes=15) SENSOR_TYPE = SensorEntityDescription( key="vacation_end_date", - name="Vacation end date", + translation_key="vacation_end_date", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, ) diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index eb44bffb134..2f76806dfd3 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -30,5 +30,12 @@ }, "application_credentials": { "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Mail. 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" + }, + "entity": { + "sensor": { + "vacation_end_date": { + "name": "Vacation end date" + } + } } } diff --git a/homeassistant/components/google_translate/__init__.py b/homeassistant/components/google_translate/__init__.py index f7860c57d99..ac6b07bd4b3 100644 --- a/homeassistant/components/google_translate/__init__.py +++ b/homeassistant/components/google_translate/__init__.py @@ -1 +1,20 @@ -"""The google_translate component.""" +"""The Google Translate text-to-speech integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.TTS] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Translate text-to-speech from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/google_translate/config_flow.py b/homeassistant/components/google_translate/config_flow.py new file mode 100644 index 00000000000..3996d41df35 --- /dev/null +++ b/homeassistant/components/google_translate/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Google Translate text-to-speech integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.tts import CONF_LANG +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + CONF_TLD, + DEFAULT_LANG, + DEFAULT_TLD, + DOMAIN, + SUPPORT_LANGUAGES, + SUPPORT_TLD, +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), + vol.Optional(CONF_TLD, default=DEFAULT_TLD): vol.In(SUPPORT_TLD), + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Google Translate text-to-speech.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self._async_abort_entries_match( + { + CONF_LANG: user_input[CONF_LANG], + CONF_TLD: user_input[CONF_TLD], + } + ) + return self.async_create_entry( + title="Google Translate text-to-speech", data=user_input + ) + + return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA) + + async def async_step_onboarding( + self, data: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by onboarding.""" + return self.async_create_entry( + title="Google Translate text-to-speech", + data={CONF_LANG: DEFAULT_LANG, CONF_TLD: DEFAULT_TLD}, + ) diff --git a/homeassistant/components/google_translate/const.py b/homeassistant/components/google_translate/const.py index e6361c1025e..0bb8663119b 100644 --- a/homeassistant/components/google_translate/const.py +++ b/homeassistant/components/google_translate/const.py @@ -1,6 +1,11 @@ -"""Constant for google_translate integration.""" +"""Constants for the Google Translate text-to-speech integration.""" from dataclasses import dataclass +CONF_TLD = "tld" +DEFAULT_LANG = "en" +DEFAULT_TLD = "com" +DOMAIN = "google_translate" + SUPPORT_LANGUAGES = [ "af", "ar", @@ -35,6 +40,7 @@ SUPPORT_LANGUAGES = [ "ko", "la", "lv", + "lt", "mk", "ml", "mr", diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index 504925a4667..7074d0ed444 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -2,6 +2,7 @@ "domain": "google_translate", "name": "Google Translate text-to-speech", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/google_translate", "iot_class": "cloud_push", "loggers": ["gtts"], diff --git a/homeassistant/components/google_translate/strings.json b/homeassistant/components/google_translate/strings.json new file mode 100644 index 00000000000..a83e61f01f9 --- /dev/null +++ b/homeassistant/components/google_translate/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "language": "Language", + "tld": "TLD" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index c02d262f6e5..45288e81996 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -1,33 +1,120 @@ """Support for the Google speech service.""" +from __future__ import annotations + from io import BytesIO import logging +from typing import Any from gtts import gTTS, gTTSError import voluptuous as vol -from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import ( + CONF_LANG, + PLATFORM_SCHEMA, + Provider, + TextToSpeechEntity, + TtsAudioType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import MAP_LANG_TLD, SUPPORT_LANGUAGES, SUPPORT_TLD +from .const import ( + CONF_TLD, + DEFAULT_LANG, + DEFAULT_TLD, + MAP_LANG_TLD, + SUPPORT_LANGUAGES, + SUPPORT_TLD, +) _LOGGER = logging.getLogger(__name__) -DEFAULT_LANG = "en" - SUPPORT_OPTIONS = ["tld"] -DEFAULT_TLD = "com" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), - vol.Optional("tld", default=DEFAULT_TLD): vol.In(SUPPORT_TLD), + vol.Optional(CONF_TLD, default=DEFAULT_TLD): vol.In(SUPPORT_TLD), } ) -async def async_get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> GoogleProvider: """Set up Google speech component.""" - return GoogleProvider(hass, config[CONF_LANG], config["tld"]) + return GoogleProvider(hass, config[CONF_LANG], config[CONF_TLD]) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Google Translate speech platform via config entry.""" + default_language = config_entry.data[CONF_LANG] + default_tld = config_entry.data[CONF_TLD] + async_add_entities([GoogleTTSEntity(config_entry, default_language, default_tld)]) + + +class GoogleTTSEntity(TextToSpeechEntity): + """The Google speech API entity.""" + + def __init__(self, config_entry: ConfigEntry, lang: str, tld: str) -> None: + """Init Google TTS service.""" + if lang in MAP_LANG_TLD: + self._lang = MAP_LANG_TLD[lang].lang + self._tld = MAP_LANG_TLD[lang].tld + else: + self._lang = lang + self._tld = tld + self._attr_name = f"Google {self._lang} {self._tld}" + self._attr_unique_id = config_entry.entry_id + + @property + def default_language(self): + """Return the default language.""" + return self._lang + + @property + def supported_languages(self): + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_options(self): + """Return a list of supported options.""" + return SUPPORT_OPTIONS + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: + """Load TTS from google.""" + tld = self._tld + if language in MAP_LANG_TLD: + tld_language = MAP_LANG_TLD[language] + tld = tld_language.tld + language = tld_language.lang + if options is not None and "tld" in options: + tld = options["tld"] + + tts = gTTS(text=message, lang=language, tld=tld) + mp3_data = BytesIO() + + try: + tts.write_to_fp(mp3_data) + except gTTSError as exc: + _LOGGER.debug( + "Error during processing of TTS request %s", exc, exc_info=True + ) + raise HomeAssistantError(exc) from exc + + return "mp3", mp3_data.getvalue() class GoogleProvider(Provider): diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 5331f6e7029..9f00e2cb52d 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_ACCURACY, @@ -55,12 +54,6 @@ WEBHOOK_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Set up the GPSLogger component.""" - hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} - return True - - async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook with GPSLogger request.""" try: @@ -95,6 +88,7 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Configure based on config entry.""" + hass.data.setdefault(DOMAIN, {"devices": set(), "unsub_device_tracker": {}}) webhook.async_register( hass, DOMAIN, "GPSLogger", entry.data[CONF_WEBHOOK_ID], handle_webhook ) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 01f98b996dd..68c11ad6e1f 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any from greeclimate.device import Device @@ -33,6 +33,10 @@ class GreeRequiredKeysMixin: class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): """Describes Gree switch entity.""" + # GreeSwitch does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + def _set_light(device: Device, value: bool) -> None: """Typed helper to set device light property.""" @@ -130,7 +134,7 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Initialize the Gree device.""" self.entity_description = description - super().__init__(coordinator, cast(str, description.name)) + super().__init__(coordinator, description.name) @property def is_on(self) -> bool: diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json index fcf4d004d26..33a4947c01d 100644 --- a/homeassistant/components/greeneye_monitor/manifest.json +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/greeneye_monitor", "iot_class": "local_push", "loggers": ["greeneye"], - "requirements": ["greeneye_monitor==3.0.3"] + "requirements": ["greeneye-monitor==3.0.3"] } diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 4543bf79d52..9480fa3ce17 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -196,9 +196,8 @@ def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[st if ent_id not in found_ids ) - else: - if entity_id not in found_ids: - found_ids.append(entity_id) + elif entity_id not in found_ids: + found_ids.append(entity_id) except AttributeError: # Raised by split_entity_id if entity_id is not a string diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 378a7852343..2747ba55ee1 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -66,7 +66,7 @@ class GroupNotifyPlatform(BaseNotificationService): payload: dict[str, Any] = {ATTR_MESSAGE: message} payload.update({key: val for key, val in kwargs.items() if val}) - tasks: list[asyncio.Task[bool | None]] = [] + tasks: list[asyncio.Task[Any]] = [] for entity in self.entities: sending_payload = deepcopy(payload.copy()) if (default_data := entity.get(ATTR_DATA)) is not None: @@ -74,7 +74,7 @@ class GroupNotifyPlatform(BaseNotificationService): tasks.append( asyncio.create_task( self.hass.services.async_call( - DOMAIN, entity[ATTR_SERVICE], sending_payload + DOMAIN, entity[ATTR_SERVICE], sending_payload, blocking=True ) ) ) diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index a25f8f0342f..87822227cef 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -138,6 +138,8 @@ async def async_setup_entry( class GrowattInverter(SensorEntity): """Representation of a Growatt Sensor.""" + _attr_has_entity_name = True + entity_description: GrowattSensorEntityDescription def __init__( @@ -147,7 +149,6 @@ class GrowattInverter(SensorEntity): self.probe = probe self.entity_description = description - self._attr_name = f"{name} {description.name}" self._attr_unique_id = unique_id self._attr_icon = "mdi:solar-power" diff --git a/homeassistant/components/growatt_server/sensor_types/inverter.py b/homeassistant/components/growatt_server/sensor_types/inverter.py index 746e4880cef..cfacadce528 100644 --- a/homeassistant/components/growatt_server/sensor_types/inverter.py +++ b/homeassistant/components/growatt_server/sensor_types/inverter.py @@ -16,7 +16,7 @@ from .sensor_entity_description import GrowattSensorEntityDescription INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( GrowattSensorEntityDescription( key="inverter_energy_today", - name="Energy today", + translation_key="inverter_energy_today", api_key="powerToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -24,7 +24,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_energy_total", - name="Lifetime energy output", + translation_key="inverter_energy_total", api_key="powerTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -33,7 +33,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_voltage_input_1", - name="Input 1 voltage", + translation_key="inverter_voltage_input_1", api_key="vpv1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -41,7 +41,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_amperage_input_1", - name="Input 1 Amperage", + translation_key="inverter_amperage_input_1", api_key="ipv1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -49,7 +49,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_wattage_input_1", - name="Input 1 Wattage", + translation_key="inverter_wattage_input_1", api_key="ppv1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -57,7 +57,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_voltage_input_2", - name="Input 2 voltage", + translation_key="inverter_voltage_input_2", api_key="vpv2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -65,7 +65,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_amperage_input_2", - name="Input 2 Amperage", + translation_key="inverter_amperage_input_2", api_key="ipv2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -73,7 +73,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_wattage_input_2", - name="Input 2 Wattage", + translation_key="inverter_wattage_input_2", api_key="ppv2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -81,7 +81,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_voltage_input_3", - name="Input 3 voltage", + translation_key="inverter_voltage_input_3", api_key="vpv3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -89,7 +89,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_amperage_input_3", - name="Input 3 Amperage", + translation_key="inverter_amperage_input_3", api_key="ipv3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -97,7 +97,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_wattage_input_3", - name="Input 3 Wattage", + translation_key="inverter_wattage_input_3", api_key="ppv3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -105,7 +105,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_internal_wattage", - name="Internal wattage", + translation_key="inverter_internal_wattage", api_key="ppv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -113,7 +113,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_reactive_voltage", - name="Reactive voltage", + translation_key="inverter_reactive_voltage", api_key="vacr", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -121,7 +121,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_inverter_reactive_amperage", - name="Reactive amperage", + translation_key="inverter_reactive_amperage", api_key="iacr", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -129,7 +129,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_frequency", - name="AC frequency", + translation_key="inverter_frequency", api_key="fac", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -137,7 +137,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_current_wattage", - name="Output power", + translation_key="inverter_current_wattage", api_key="pac", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -145,7 +145,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_current_reactive_wattage", - name="Reactive wattage", + translation_key="inverter_current_reactive_wattage", api_key="pacr", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -153,7 +153,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_ipm_temperature", - name="Intelligent Power Management temperature", + translation_key="inverter_ipm_temperature", api_key="ipmTemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -161,7 +161,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="inverter_temperature", - name="Temperature", + translation_key="inverter_temperature", api_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/growatt_server/sensor_types/mix.py b/homeassistant/components/growatt_server/sensor_types/mix.py index 76d37f4d193..e9722abda11 100644 --- a/homeassistant/components/growatt_server/sensor_types/mix.py +++ b/homeassistant/components/growatt_server/sensor_types/mix.py @@ -15,21 +15,21 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( # Values from 'mix_info' API call GrowattSensorEntityDescription( key="mix_statement_of_charge", - name="Statement of charge", + translation_key="mix_statement_of_charge", api_key="capacity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), GrowattSensorEntityDescription( key="mix_battery_charge_today", - name="Battery charged today", + translation_key="mix_battery_charge_today", api_key="eBatChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_battery_charge_lifetime", - name="Lifetime battery charged", + translation_key="mix_battery_charge_lifetime", api_key="eBatChargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -37,14 +37,14 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="mix_battery_discharge_today", - name="Battery discharged today", + translation_key="mix_battery_discharge_today", api_key="eBatDisChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_battery_discharge_lifetime", - name="Lifetime battery discharged", + translation_key="mix_battery_discharge_lifetime", api_key="eBatDisChargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -52,14 +52,14 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="mix_solar_generation_today", - name="Solar energy today", + translation_key="mix_solar_generation_today", api_key="epvToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_solar_generation_lifetime", - name="Lifetime solar energy", + translation_key="mix_solar_generation_lifetime", api_key="epvTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -67,28 +67,28 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="mix_battery_discharge_w", - name="Battery discharging W", + translation_key="mix_battery_discharge_w", api_key="pDischarge1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_battery_voltage", - name="Battery voltage", + translation_key="mix_battery_voltage", api_key="vbat", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, ), GrowattSensorEntityDescription( key="mix_pv1_voltage", - name="PV1 voltage", + translation_key="mix_pv1_voltage", api_key="vpv1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, ), GrowattSensorEntityDescription( key="mix_pv2_voltage", - name="PV2 voltage", + translation_key="mix_pv2_voltage", api_key="vpv2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -96,14 +96,14 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( # Values from 'mix_totals' API call GrowattSensorEntityDescription( key="mix_load_consumption_today", - name="Load consumption today", + translation_key="mix_load_consumption_today", api_key="elocalLoadToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_load_consumption_lifetime", - name="Lifetime load consumption", + translation_key="mix_load_consumption_lifetime", api_key="elocalLoadTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -111,14 +111,14 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="mix_export_to_grid_today", - name="Export to grid today", + translation_key="mix_export_to_grid_today", api_key="etoGridToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_export_to_grid_lifetime", - name="Lifetime export to grid", + translation_key="mix_export_to_grid_lifetime", api_key="etogridTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -127,63 +127,63 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( # Values from 'mix_system_status' API call GrowattSensorEntityDescription( key="mix_battery_charge", - name="Battery charging", + translation_key="mix_battery_charge", api_key="chargePower", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_load_consumption", - name="Load consumption", + translation_key="mix_load_consumption", api_key="pLocalLoad", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_wattage_pv_1", - name="PV1 Wattage", + translation_key="mix_wattage_pv_1", api_key="pPv1", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_wattage_pv_2", - name="PV2 Wattage", + translation_key="mix_wattage_pv_2", api_key="pPv2", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_wattage_pv_all", - name="All PV Wattage", + translation_key="mix_wattage_pv_all", api_key="ppv", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_export_to_grid", - name="Export to grid", + translation_key="mix_export_to_grid", api_key="pactogrid", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_import_from_grid", - name="Import from grid", + translation_key="mix_import_from_grid", api_key="pactouser", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_battery_discharge_kw", - name="Battery discharging kW", + translation_key="mix_battery_discharge_kw", api_key="pdisCharge1", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_grid_voltage", - name="Grid voltage", + translation_key="mix_grid_voltage", api_key="vAc1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -191,35 +191,35 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( # Values from 'mix_detail' API call GrowattSensorEntityDescription( key="mix_system_production_today", - name="System production today (self-consumption + export)", + translation_key="mix_system_production_today", api_key="eCharge", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_load_consumption_solar_today", - name="Load consumption today (solar)", + translation_key="mix_load_consumption_solar_today", api_key="eChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_self_consumption_today", - name="Self consumption today (solar + battery)", + translation_key="mix_self_consumption_today", api_key="eChargeToday1", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_load_consumption_battery_today", - name="Load consumption today (battery)", + translation_key="mix_load_consumption_battery_today", api_key="echarge1", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_import_from_grid_today", - name="Import from grid today (load)", + translation_key="mix_import_from_grid_today", api_key="etouser", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -227,14 +227,14 @@ MIX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( # This sensor is manually created using the most recent X-Axis value from the chartData GrowattSensorEntityDescription( key="mix_last_update", - name="Last Data Update", + translation_key="mix_last_update", api_key="lastdataupdate", device_class=SensorDeviceClass.TIMESTAMP, ), # Values from 'dashboard_data' API call GrowattSensorEntityDescription( key="mix_import_from_grid_today_combined", - name="Import from grid today (load + charging)", + translation_key="mix_import_from_grid_today_combined", api_key="etouser_combined", # This id is not present in the raw API data, it is added by the sensor native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, diff --git a/homeassistant/components/growatt_server/sensor_types/storage.py b/homeassistant/components/growatt_server/sensor_types/storage.py index d1305aa879d..4b60a73c979 100644 --- a/homeassistant/components/growatt_server/sensor_types/storage.py +++ b/homeassistant/components/growatt_server/sensor_types/storage.py @@ -16,14 +16,14 @@ from .sensor_entity_description import GrowattSensorEntityDescription STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( GrowattSensorEntityDescription( key="storage_storage_production_today", - name="Storage production today", + translation_key="storage_storage_production_today", api_key="eBatDisChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="storage_storage_production_lifetime", - name="Lifetime Storage production", + translation_key="storage_storage_production_lifetime", api_key="eBatDisChargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -31,21 +31,21 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_grid_discharge_today", - name="Grid discharged today", + translation_key="storage_grid_discharge_today", api_key="eacDisChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="storage_load_consumption_today", - name="Load consumption today", + translation_key="storage_load_consumption_today", api_key="eopDischrToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="storage_load_consumption_lifetime", - name="Lifetime load consumption", + translation_key="storage_load_consumption_lifetime", api_key="eopDischrTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -53,14 +53,14 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_grid_charged_today", - name="Grid charged today", + translation_key="storage_grid_charged_today", api_key="eacChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="storage_charge_storage_lifetime", - name="Lifetime storaged charged", + translation_key="storage_charge_storage_lifetime", api_key="eChargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -68,55 +68,55 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_solar_production", - name="Solar power production", + translation_key="storage_solar_production", api_key="ppv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="storage_battery_percentage", - name="Battery percentage", + translation_key="storage_battery_percentage", api_key="capacity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), GrowattSensorEntityDescription( key="storage_power_flow", - name="Storage charging/ discharging(-ve)", + translation_key="storage_power_flow", api_key="pCharge", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="storage_load_consumption_solar_storage", - name="Load consumption(Solar + Storage)", + translation_key="storage_load_consumption_solar_storage", api_key="rateVA", native_unit_of_measurement="VA", ), GrowattSensorEntityDescription( key="storage_charge_today", - name="Charge today", + translation_key="storage_charge_today", api_key="eChargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="storage_import_from_grid", - name="Import from grid", + translation_key="storage_import_from_grid", api_key="pAcInPut", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="storage_import_from_grid_today", - name="Import from grid today", + translation_key="storage_import_from_grid_today", api_key="eToUserToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="storage_import_from_grid_total", - name="Import from grid total", + translation_key="storage_import_from_grid_total", api_key="eToUserTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -124,14 +124,14 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_load_consumption", - name="Load consumption", + translation_key="storage_load_consumption", api_key="outPutPower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="storage_grid_voltage", - name="AC input voltage", + translation_key="storage_grid_voltage", api_key="vGrid", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -139,7 +139,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_pv_charging_voltage", - name="PV charging voltage", + translation_key="storage_pv_charging_voltage", api_key="vpv", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -147,7 +147,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_ac_input_frequency_out", - name="AC input frequency", + translation_key="storage_ac_input_frequency_out", api_key="freqOutPut", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -155,7 +155,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_output_voltage", - name="Output voltage", + translation_key="storage_output_voltage", api_key="outPutVolt", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -163,7 +163,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_ac_output_frequency", - name="Ac output frequency", + translation_key="storage_ac_output_frequency", api_key="freqGrid", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -171,7 +171,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_current_PV", - name="Solar charge current", + translation_key="storage_current_pv", api_key="iAcCharge", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -179,7 +179,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_current_1", - name="Solar current to storage", + translation_key="storage_current_1", api_key="iChargePV1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -187,7 +187,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_grid_amperage_input", - name="Grid charge current", + translation_key="storage_grid_amperage_input", api_key="chgCurr", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -195,7 +195,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_grid_out_current", - name="Grid out current", + translation_key="storage_grid_out_current", api_key="outPutCurrent", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -203,7 +203,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_battery_voltage", - name="Battery voltage", + translation_key="storage_battery_voltage", api_key="vBat", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -211,7 +211,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="storage_load_percentage", - name="Load percentage", + translation_key="storage_load_percentage", api_key="loadPercent", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, diff --git a/homeassistant/components/growatt_server/sensor_types/tlx.py b/homeassistant/components/growatt_server/sensor_types/tlx.py index 9c9bbd488d5..645b32db9d0 100644 --- a/homeassistant/components/growatt_server/sensor_types/tlx.py +++ b/homeassistant/components/growatt_server/sensor_types/tlx.py @@ -20,7 +20,7 @@ from .sensor_entity_description import GrowattSensorEntityDescription TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( GrowattSensorEntityDescription( key="tlx_energy_today", - name="Energy today", + translation_key="tlx_energy_today", api_key="eacToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -29,7 +29,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_energy_total", - name="Lifetime energy output", + translation_key="tlx_energy_total", api_key="eacTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -39,7 +39,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_energy_total_input_1", - name="Lifetime total energy input 1", + translation_key="tlx_energy_total_input_1", api_key="epv1Total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -49,7 +49,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_energy_today_input_1", - name="Energy Today Input 1", + translation_key="tlx_energy_today_input_1", api_key="epv1Today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -58,7 +58,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_voltage_input_1", - name="Input 1 voltage", + translation_key="tlx_voltage_input_1", api_key="vpv1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -66,7 +66,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_amperage_input_1", - name="Input 1 Amperage", + translation_key="tlx_amperage_input_1", api_key="ipv1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -74,7 +74,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_wattage_input_1", - name="Input 1 Wattage", + translation_key="tlx_wattage_input_1", api_key="ppv1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -82,7 +82,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_energy_total_input_2", - name="Lifetime total energy input 2", + translation_key="tlx_energy_total_input_2", api_key="epv2Total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -92,7 +92,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_energy_today_input_2", - name="Energy Today Input 2", + translation_key="tlx_energy_today_input_2", api_key="epv2Today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -101,7 +101,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_voltage_input_2", - name="Input 2 voltage", + translation_key="tlx_voltage_input_2", api_key="vpv2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -109,7 +109,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_amperage_input_2", - name="Input 2 Amperage", + translation_key="tlx_amperage_input_2", api_key="ipv2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -117,7 +117,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_wattage_input_2", - name="Input 2 Wattage", + translation_key="tlx_wattage_input_2", api_key="ppv2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -125,7 +125,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_energy_total_input_3", - name="Lifetime total energy input 3", + translation_key="tlx_energy_total_input_3", api_key="epv3Total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -135,7 +135,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_energy_today_input_3", - name="Energy Today Input 3", + translation_key="tlx_energy_today_input_3", api_key="epv3Today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -144,7 +144,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_voltage_input_3", - name="Input 3 voltage", + translation_key="tlx_voltage_input_3", api_key="vpv3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -152,7 +152,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_amperage_input_3", - name="Input 3 Amperage", + translation_key="tlx_amperage_input_3", api_key="ipv3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -160,7 +160,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_wattage_input_3", - name="Input 3 Wattage", + translation_key="tlx_wattage_input_3", api_key="ppv3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -168,7 +168,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_energy_total_input_4", - name="Lifetime total energy input 4", + translation_key="tlx_energy_total_input_4", api_key="epv4Total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -178,7 +178,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_energy_today_input_4", - name="Energy Today Input 4", + translation_key="tlx_energy_today_input_4", api_key="epv4Today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -187,7 +187,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_voltage_input_4", - name="Input 4 voltage", + translation_key="tlx_voltage_input_4", api_key="vpv4", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -195,7 +195,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_amperage_input_4", - name="Input 4 Amperage", + translation_key="tlx_amperage_input_4", api_key="ipv4", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -203,7 +203,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_wattage_input_4", - name="Input 4 Wattage", + translation_key="tlx_wattage_input_4", api_key="ppv4", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -211,7 +211,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_solar_generation_total", - name="Lifetime total solar energy", + translation_key="tlx_solar_generation_total", api_key="epvTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -220,7 +220,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_internal_wattage", - name="Internal wattage", + translation_key="tlx_internal_wattage", api_key="ppv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -228,7 +228,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_reactive_voltage", - name="Reactive voltage", + translation_key="tlx_reactive_voltage", api_key="vacrs", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -236,7 +236,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_frequency", - name="AC frequency", + translation_key="tlx_frequency", api_key="fac", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -244,7 +244,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_current_wattage", - name="Output power", + translation_key="tlx_current_wattage", api_key="pac", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -252,7 +252,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_temperature_1", - name="Temperature 1", + translation_key="tlx_temperature_1", api_key="temp1", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -260,7 +260,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_temperature_2", - name="Temperature 2", + translation_key="tlx_temperature_2", api_key="temp2", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -268,7 +268,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_temperature_3", - name="Temperature 3", + translation_key="tlx_temperature_3", api_key="temp3", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -276,7 +276,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_temperature_4", - name="Temperature 4", + translation_key="tlx_temperature_4", api_key="temp4", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -284,7 +284,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_temperature_5", - name="Temperature 5", + translation_key="tlx_temperature_5", api_key="temp5", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -292,7 +292,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_all_batteries_discharge_today", - name="All batteries discharged today", + translation_key="tlx_all_batteries_discharge_today", api_key="edischargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -300,7 +300,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_all_batteries_discharge_total", - name="Lifetime total all batteries discharged", + translation_key="tlx_all_batteries_discharge_total", api_key="edischargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -309,14 +309,14 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_battery_1_discharge_w", - name="Battery 1 discharging W", + translation_key="tlx_battery_1_discharge_w", api_key="bdc1DischargePower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="tlx_battery_1_discharge_total", - name="Lifetime total battery 1 discharged", + translation_key="tlx_battery_1_discharge_total", api_key="bdc1DischargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -325,14 +325,14 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_battery_2_discharge_w", - name="Battery 2 discharging W", + translation_key="tlx_battery_2_discharge_w", api_key="bdc1DischargePower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="tlx_battery_2_discharge_total", - name="Lifetime total battery 2 discharged", + translation_key="tlx_battery_2_discharge_total", api_key="bdc1DischargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -341,7 +341,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_all_batteries_charge_today", - name="All batteries charged today", + translation_key="tlx_all_batteries_charge_today", api_key="echargeToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -349,7 +349,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_all_batteries_charge_total", - name="Lifetime total all batteries charged", + translation_key="tlx_all_batteries_charge_total", api_key="echargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -358,14 +358,14 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_battery_1_charge_w", - name="Battery 1 charging W", + translation_key="tlx_battery_1_charge_w", api_key="bdc1ChargePower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="tlx_battery_1_charge_total", - name="Lifetime total battery 1 charged", + translation_key="tlx_battery_1_charge_total", api_key="bdc1ChargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -374,14 +374,14 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_battery_2_charge_w", - name="Battery 2 charging W", + translation_key="tlx_battery_2_charge_w", api_key="bdc1ChargePower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="tlx_battery_2_charge_total", - name="Lifetime total battery 2 charged", + translation_key="tlx_battery_2_charge_total", api_key="bdc1ChargeTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -390,7 +390,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_export_to_grid_today", - name="Export to grid today", + translation_key="tlx_export_to_grid_today", api_key="etoGridToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -398,7 +398,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_export_to_grid_total", - name="Lifetime total export to grid", + translation_key="tlx_export_to_grid_total", api_key="etoGridTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -407,7 +407,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_load_consumption_today", - name="Load consumption today", + translation_key="tlx_load_consumption_today", api_key="elocalLoadToday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -415,7 +415,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="mix_load_consumption_total", - name="Lifetime total load consumption", + translation_key="mix_load_consumption_total", api_key="elocalLoadTotal", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -424,7 +424,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="tlx_statement_of_charge", - name="Statement of charge (SoC)", + translation_key="tlx_statement_of_charge", api_key="bmsSoc", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, diff --git a/homeassistant/components/growatt_server/sensor_types/total.py b/homeassistant/components/growatt_server/sensor_types/total.py index 2056443af3d..5945ad20e40 100644 --- a/homeassistant/components/growatt_server/sensor_types/total.py +++ b/homeassistant/components/growatt_server/sensor_types/total.py @@ -9,33 +9,33 @@ from .sensor_entity_description import GrowattSensorEntityDescription TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( GrowattSensorEntityDescription( key="total_money_today", - name="Total money today", + translation_key="total_money_today", api_key="plantMoneyText", currency=True, ), GrowattSensorEntityDescription( key="total_money_total", - name="Money lifetime", + translation_key="total_money_total", api_key="totalMoneyText", currency=True, ), GrowattSensorEntityDescription( key="total_energy_today", - name="Energy Today", + translation_key="total_energy_today", api_key="todayEnergy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="total_output_power", - name="Output Power", + translation_key="total_output_power", api_key="invTodayPpv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="total_energy_output", - name="Lifetime energy output", + translation_key="total_energy_output", api_key="totalEnergy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -43,7 +43,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( ), GrowattSensorEntityDescription( key="total_maximum_output", - name="Maximum power", + translation_key="total_maximum_output", api_key="nominalPower", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 695b8a08c1c..d2c196dbfdd 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -25,5 +25,405 @@ } } }, - "title": "Growatt Server" + "title": "Growatt Server", + "entity": { + "sensor": { + "inverter_energy_today": { + "name": "Energy today" + }, + "inverter_energy_total": { + "name": "Lifetime energy output" + }, + "inverter_voltage_input_1": { + "name": "Input 1 voltage" + }, + "inverter_amperage_input_1": { + "name": "Input 1 Amperage" + }, + "inverter_wattage_input_1": { + "name": "Input 1 Wattage" + }, + "inverter_voltage_input_2": { + "name": "Input 2 voltage" + }, + "inverter_amperage_input_2": { + "name": "Input 2 Amperage" + }, + "inverter_wattage_input_2": { + "name": "Input 2 Wattage" + }, + "inverter_voltage_input_3": { + "name": "Input 3 voltage" + }, + "inverter_amperage_input_3": { + "name": "Input 3 Amperage" + }, + "inverter_wattage_input_3": { + "name": "Input 3 Wattage" + }, + "inverter_internal_wattage": { + "name": "Internal wattage" + }, + "inverter_reactive_voltage": { + "name": "Reactive voltage" + }, + "inverter_reactive_amperage": { + "name": "Reactive amperage" + }, + "inverter_frequency": { + "name": "AC frequency" + }, + "inverter_current_wattage": { + "name": "Output power" + }, + "inverter_current_reactive_wattage": { + "name": "Reactive wattage" + }, + "inverter_ipm_temperature": { + "name": "Intelligent Power Management temperature" + }, + "inverter_temperature": { + "name": "Inverter temperature" + }, + "mix_statement_of_charge": { + "name": "Statement of charge" + }, + "mix_battery_charge_today": { + "name": "Battery charged today" + }, + "mix_battery_charge_lifetime": { + "name": "Lifetime battery charged" + }, + "mix_battery_discharge_today": { + "name": "Battery discharged today" + }, + "mix_battery_discharge_lifetime": { + "name": "Lifetime battery discharged" + }, + "mix_solar_generation_today": { + "name": "Solar energy today" + }, + "mix_solar_generation_lifetime": { + "name": "Lifetime solar energy" + }, + "mix_battery_discharge_w": { + "name": "Battery discharging W" + }, + "mix_battery_voltage": { + "name": "Battery voltage" + }, + "mix_pv1_voltage": { + "name": "PV1 voltage" + }, + "mix_pv2_voltage": { + "name": "PV2 voltage" + }, + "mix_load_consumption_today": { + "name": "Load consumption today" + }, + "mix_load_consumption_lifetime": { + "name": "Lifetime load consumption" + }, + "mix_export_to_grid_today": { + "name": "Export to grid today" + }, + "mix_export_to_grid_lifetime": { + "name": "Lifetime export to grid" + }, + "mix_battery_charge": { + "name": "Battery charging" + }, + "mix_load_consumption": { + "name": "Load consumption" + }, + "mix_wattage_pv_1": { + "name": "PV1 Wattage" + }, + "mix_wattage_pv_2": { + "name": "PV2 Wattage" + }, + "mix_wattage_pv_all": { + "name": "All PV Wattage" + }, + "mix_export_to_grid": { + "name": "Export to grid" + }, + "mix_import_from_grid": { + "name": "Import from grid" + }, + "mix_battery_discharge_kw": { + "name": "Battery discharging kW" + }, + "mix_grid_voltage": { + "name": "Grid voltage" + }, + "mix_system_production_today": { + "name": "System production today (self-consumption + export)" + }, + "mix_load_consumption_solar_today": { + "name": "Load consumption today (solar)" + }, + "mix_self_consumption_today": { + "name": "Self consumption today (solar + battery)" + }, + "mix_load_consumption_battery_today": { + "name": "Load consumption today (battery)" + }, + "mix_import_from_grid_today": { + "name": "Import from grid today (load)" + }, + "mix_last_update": { + "name": "Last Data Update" + }, + "mix_import_from_grid_today_combined": { + "name": "Import from grid today (load + charging)" + }, + "storage_storage_production_today": { + "name": "Storage production today" + }, + "storage_storage_production_lifetime": { + "name": "Lifetime Storage production" + }, + "storage_grid_discharge_today": { + "name": "Grid discharged today" + }, + "storage_load_consumption_today": { + "name": "Load consumption today" + }, + "storage_load_consumption_lifetime": { + "name": "Lifetime load consumption" + }, + "storage_grid_charged_today": { + "name": "Grid charged today" + }, + "storage_charge_storage_lifetime": { + "name": "Lifetime stored charged" + }, + "storage_solar_production": { + "name": "Solar power production" + }, + "storage_battery_percentage": { + "name": "Battery percentage" + }, + "storage_power_flow": { + "name": "Storage charging/ discharging(-ve)" + }, + "storage_load_consumption_solar_storage": { + "name": "Load consumption (Solar + Storage)" + }, + "storage_charge_today": { + "name": "Charge today" + }, + "storage_import_from_grid": { + "name": "Import from grid" + }, + "storage_import_from_grid_today": { + "name": "Import from grid today" + }, + "storage_import_from_grid_total": { + "name": "Import from grid total" + }, + "storage_load_consumption": { + "name": "Load consumption" + }, + "storage_grid_voltage": { + "name": "AC input voltage" + }, + "storage_pv_charging_voltage": { + "name": "PV charging voltage" + }, + "storage_ac_input_frequency_out": { + "name": "AC input frequency" + }, + "storage_output_voltage": { + "name": "Output voltage" + }, + "storage_ac_output_frequency": { + "name": "Ac output frequency" + }, + "storage_current_pv": { + "name": "Solar charge current" + }, + "storage_current_1": { + "name": "Solar current to storage" + }, + "storage_grid_amperage_input": { + "name": "Grid charge current" + }, + "storage_grid_out_current": { + "name": "Grid out current" + }, + "storage_battery_voltage": { + "name": "Battery voltage" + }, + "storage_load_percentage": { + "name": "Load percentage" + }, + "tlx_energy_today": { + "name": "Energy today" + }, + "tlx_energy_total": { + "name": "Lifetime energy output" + }, + "tlx_energy_total_input_1": { + "name": "Lifetime total energy input 1" + }, + "tlx_energy_today_input_1": { + "name": "Energy Today Input 1" + }, + "tlx_voltage_input_1": { + "name": "Input 1 voltage" + }, + "tlx_amperage_input_1": { + "name": "Input 1 Amperage" + }, + "tlx_wattage_input_1": { + "name": "Input 1 Wattage" + }, + "tlx_energy_total_input_2": { + "name": "Lifetime total energy input 2" + }, + "tlx_energy_today_input_2": { + "name": "Energy Today Input 2" + }, + "tlx_voltage_input_2": { + "name": "Input 2 voltage" + }, + "tlx_amperage_input_2": { + "name": "Input 2 Amperage" + }, + "tlx_wattage_input_2": { + "name": "Input 2 Wattage" + }, + "tlx_energy_total_input_3": { + "name": "Lifetime total energy input 3" + }, + "tlx_energy_today_input_3": { + "name": "Energy Today Input 3" + }, + "tlx_voltage_input_3": { + "name": "Input 3 voltage" + }, + "tlx_amperage_input_3": { + "name": "Input 3 Amperage" + }, + "tlx_wattage_input_3": { + "name": "Input 3 Wattage" + }, + "tlx_energy_total_input_4": { + "name": "Lifetime total energy input 4" + }, + "tlx_energy_today_input_4": { + "name": "Energy Today Input 4" + }, + "tlx_voltage_input_4": { + "name": "Input 4 voltage" + }, + "tlx_amperage_input_4": { + "name": "Input 4 Amperage" + }, + "tlx_wattage_input_4": { + "name": "Input 4 Wattage" + }, + "tlx_solar_generation_total": { + "name": "Lifetime total solar energy" + }, + "tlx_internal_wattage": { + "name": "Internal wattage" + }, + "tlx_reactive_voltage": { + "name": "Reactive voltage" + }, + "tlx_frequency": { + "name": "AC frequency" + }, + "tlx_current_wattage": { + "name": "Output power" + }, + "tlx_temperature_1": { + "name": "Temperature 1" + }, + "tlx_temperature_2": { + "name": "Temperature 2" + }, + "tlx_temperature_3": { + "name": "Temperature 3" + }, + "tlx_temperature_4": { + "name": "Temperature 4" + }, + "tlx_temperature_5": { + "name": "Temperature 5" + }, + "tlx_all_batteries_discharge_today": { + "name": "All batteries discharged today" + }, + "tlx_all_batteries_discharge_total": { + "name": "Lifetime total all batteries discharged" + }, + "tlx_battery_1_discharge_w": { + "name": "Battery 1 discharging W" + }, + "tlx_battery_1_discharge_total": { + "name": "Lifetime total battery 1 discharged" + }, + "tlx_battery_2_discharge_w": { + "name": "Battery 2 discharging W" + }, + "tlx_battery_2_discharge_total": { + "name": "Lifetime total battery 2 discharged" + }, + "tlx_all_batteries_charge_today": { + "name": "All batteries charged today" + }, + "tlx_all_batteries_charge_total": { + "name": "Lifetime total all batteries charged" + }, + "tlx_battery_1_charge_w": { + "name": "Battery 1 charging W" + }, + "tlx_battery_1_charge_total": { + "name": "Lifetime total battery 1 charged" + }, + "tlx_battery_2_charge_w": { + "name": "Battery 2 charging W" + }, + "tlx_battery_2_charge_total": { + "name": "Lifetime total battery 2 charged" + }, + "tlx_export_to_grid_today": { + "name": "Export to grid today" + }, + "tlx_export_to_grid_total": { + "name": "Lifetime total export to grid" + }, + "tlx_load_consumption_today": { + "name": "Load consumption today" + }, + "mix_load_consumption_total": { + "name": "Lifetime total load consumption" + }, + "tlx_statement_of_charge": { + "name": "Statement of charge (SoC)" + }, + "total_money_today": { + "name": "Total money today" + }, + "total_money_total": { + "name": "Money lifetime" + }, + "total_energy_today": { + "name": "Energy Today" + }, + "total_output_power": { + "name": "Output Power" + }, + "total_energy_output": { + "name": "Lifetime energy output" + }, + "total_maximum_output": { + "name": "Maximum power" + } + } + } } diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 9fac4d01926..6f8daf2918d 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -341,7 +341,7 @@ def get_next_departure( {tomorrow_order} origin_stop_time.departure_time LIMIT :limit - """ + """ # noqa: S608 result = schedule.engine.connect().execute( text(sql_query), { @@ -643,15 +643,14 @@ class GTFSDepartureSensor(SensorEntity): # Define the state as a UTC timestamp with ISO 8601 format if not self._departure: self._state = None + elif self._agency: + self._state = self._departure["departure_time"].replace( + tzinfo=dt_util.get_time_zone(self._agency.agency_timezone) + ) else: - if self._agency: - self._state = self._departure["departure_time"].replace( - tzinfo=dt_util.get_time_zone(self._agency.agency_timezone) - ) - else: - self._state = self._departure["departure_time"].replace( - tzinfo=dt_util.UTC - ) + self._state = self._departure["departure_time"].replace( + tzinfo=dt_util.UTC + ) # Assign attributes, icon and name self.update_attributes() diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index f9e8819a6af..7114d33f93a 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -49,12 +49,12 @@ class ValveControllerBinarySensorDescription( PAIRED_SENSOR_DESCRIPTIONS = ( BinarySensorEntityDescription( key=SENSOR_KIND_LEAK_DETECTED, - name="Leak detected", + translation_key="leak", device_class=BinarySensorDeviceClass.MOISTURE, ), BinarySensorEntityDescription( key=SENSOR_KIND_MOVED, - name="Recently moved", + translation_key="moved", device_class=BinarySensorDeviceClass.MOVING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -63,7 +63,7 @@ PAIRED_SENSOR_DESCRIPTIONS = ( VALVE_CONTROLLER_DESCRIPTIONS = ( ValveControllerBinarySensorDescription( key=SENSOR_KIND_LEAK_DETECTED, - name="Leak detected", + translation_key="leak", device_class=BinarySensorDeviceClass.MOISTURE, api_category=API_SYSTEM_ONBOARD_SENSOR_STATUS, ), diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index 7680707641c..c6363c9bcec 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -56,15 +56,15 @@ async def _async_valve_reset(client: Client) -> None: BUTTON_DESCRIPTIONS = ( ValveControllerButtonDescription( key=BUTTON_KIND_REBOOT, - name="Reboot", push_action=_async_reboot, + device_class=ButtonDeviceClass.RESTART, # Buttons don't actually need a coordinator; we give them one so they can # properly inherit from GuardianEntity: api_category=API_SYSTEM_DIAGNOSTICS, ), ValveControllerButtonDescription( key=BUTTON_KIND_RESET_VALVE_DIAGNOSTICS, - name="Reset valve diagnostics", + translation_key="reset_diagnostics", push_action=_async_valve_reset, # Buttons don't actually need a coordinator; we give them one so they can # properly inherit from GuardianEntity: diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index c46f6e221b2..c5fc77cc8f9 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -49,14 +49,12 @@ class ValveControllerSensorDescription( PAIRED_SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=SENSOR_KIND_BATTERY, - name="Battery", device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricPotential.VOLT, ), SensorEntityDescription( key=SENSOR_KIND_TEMPERATURE, - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, state_class=SensorStateClass.MEASUREMENT, @@ -65,7 +63,6 @@ PAIRED_SENSOR_DESCRIPTIONS = ( VALVE_CONTROLLER_DESCRIPTIONS = ( ValveControllerSensorDescription( key=SENSOR_KIND_TEMPERATURE, - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, state_class=SensorStateClass.MEASUREMENT, @@ -73,7 +70,7 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( ), ValveControllerSensorDescription( key=SENSOR_KIND_UPTIME, - name="Uptime", + translation_key="uptime", icon="mdi:timer", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTime.MINUTES, diff --git a/homeassistant/components/guardian/strings.json b/homeassistant/components/guardian/strings.json index 683f13c8d36..dc3e6f4c17d 100644 --- a/homeassistant/components/guardian/strings.json +++ b/homeassistant/components/guardian/strings.json @@ -30,5 +30,33 @@ } } } + }, + "entity": { + "binary_sensor": { + "leak": { + "name": "Leak detected" + }, + "moved": { + "name": "Recently moved" + } + }, + "button": { + "reset_diagnostics": { + "name": "Reset valve diagnostics" + } + }, + "sensor": { + "uptime": { + "name": "Uptime" + } + }, + "switch": { + "onboard_access_point": { + "name": "Onboard access point" + }, + "valve_controller": { + "name": "Valve controller" + } + } } } diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index fe6ff937b84..4e2be5ae179 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -67,7 +67,7 @@ async def _async_open_valve(client: Client) -> None: VALVE_CONTROLLER_DESCRIPTIONS = ( ValveControllerSwitchDescription( key=SWITCH_KIND_ONBOARD_AP, - name="Onboard AP", + translation_key="onboard_access_point", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, api_category=API_WIFI_STATUS, @@ -76,7 +76,7 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( ), ValveControllerSwitchDescription( key=SWITCH_KIND_VALVE, - name="Valve controller", + translation_key="valve_controller", icon="mdi:water", api_category=API_VALVE_STATUS, off_action=_async_close_valve, diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 1482c8aaa4d..c1e85c86787 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -216,9 +216,8 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): if not activity or activity == PREVIOUS_ACTIVE_ACTIVITY: if self._last_activity: activity = self._last_activity - else: - if all_activities := self._data.activity_names: - activity = all_activities[0] + elif all_activities := self._data.activity_names: + activity = all_activities[0] if activity: await self._data.async_start_activity(activity) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 2ae4faa7878..8c7f86700e7 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -159,7 +159,9 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( SCHEMA_BACKUP_FULL = vol.Schema( { - vol.Optional(ATTR_NAME): cv.string, + vol.Optional( + ATTR_NAME, default=lambda: utcnow().strftime("%Y-%m-%d %H:%M:%S") + ): cv.string, vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_COMPRESSED): cv.boolean, vol.Optional(ATTR_LOCATION): vol.All( diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 8f7f06a3931..0bbd89aab86 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -274,12 +274,13 @@ class SupervisorIssues: data["suggestions"] = ( await self._client.get_suggestions_for_issue(data["uuid"]) )[ATTR_SUGGESTIONS] - self.add_issue(Issue.from_dict(data)) except HassioAPIError: _LOGGER.error( "Could not get suggestions for supervisor issue %s, skipping it", data["uuid"], ) + return + self.add_issue(Issue.from_dict(data)) def remove_issue(self, issue: Issue) -> None: """Remove an issue from the list. Delete a repair if necessary.""" diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 8a9a145f2d6..c8fefe65e1f 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -41,9 +41,14 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema( # Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` # pylint: disable=implicit-str-concat +# fmt: off WS_NO_ADMIN_ENDPOINTS = re.compile( - r"^(?:" r"|/ingress/(session|validate_session)" r"|/addons/[^/]+/info" r")$" + r"^(?:" + r"|/ingress/(session|validate_session)" + r"|/addons/[^/]+/info" + r")$" # noqa: ISC001 ) +# fmt: on # pylint: enable=implicit-str-concat _LOGGER: logging.Logger = logging.getLogger(__package__) diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json index f024b55d009..19c5c4d73d9 100644 --- a/homeassistant/components/here_travel_time/manifest.json +++ b/homeassistant/components/here_travel_time/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/here_travel_time", "iot_class": "cloud_polling", "loggers": ["here_routing", "here_transit", "homeassistant.helpers.location"], - "requirements": ["here_routing==0.2.0", "here_transit==1.2.0"] + "requirements": ["here-routing==0.2.0", "here-transit==1.2.0"] } diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 91abfbd7652..537f782ad09 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -52,21 +52,21 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] """Construct SensorEntityDescriptions.""" return ( SensorEntityDescription( - name="Duration", + translation_key="duration", icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DURATION, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( - name="Duration in traffic", + translation_key="duration_in_traffic", icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DURATION_IN_TRAFFIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( - name="Distance", + translation_key="distance", icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DISTANCE, state_class=SensorStateClass.MEASUREMENT, @@ -110,6 +110,8 @@ class HERETravelTimeSensor( ): """Representation of a HERE travel time sensor.""" + _attr_has_entity_name = True + def __init__( self, unique_id_prefix: str, @@ -128,7 +130,6 @@ class HERETravelTimeSensor( name=name, manufacturer="HERE Technologies", ) - self._attr_has_entity_name = True async def _async_restore_state(self) -> None: """Restore state.""" @@ -174,7 +175,7 @@ class OriginSensor(HERETravelTimeSensor): ) -> None: """Initialize the sensor.""" sensor_description = SensorEntityDescription( - name="Origin", + translation_key="origin", icon="mdi:store-marker", key=ATTR_ORIGIN_NAME, ) @@ -202,7 +203,7 @@ class DestinationSensor(HERETravelTimeSensor): ) -> None: """Initialize the sensor.""" sensor_description = SensorEntityDescription( - name="Destination", + translation_key="destination", icon="mdi:store-marker", key=ATTR_DESTINATION_NAME, ) diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index dab135efc82..2c031dc0a02 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -85,5 +85,24 @@ } } } + }, + "entity": { + "sensor": { + "duration": { + "name": "Duration" + }, + "duration_in_traffic": { + "name": "Duration in traffic" + }, + "distance": { + "name": "Distance" + }, + "origin": { + "name": "Origin" + }, + "destination": { + "name": "Destination" + } + } } } diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index 1e175a2a0df..e37e149ccda 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/hikvision", "iot_class": "local_push", "loggers": ["pyhik"], - "requirements": ["pyhik==0.3.2"] + "requirements": ["pyHik==0.3.2"] } diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 38a45ccf709..efd2a9b34dd 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -62,28 +62,27 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): status = self.device.appliance.status if self._key not in status: self._state = None - else: - if self.device_class == SensorDeviceClass.TIMESTAMP: - if ATTR_VALUE not in status[self._key]: - self._state = None - elif ( - self._state is not None - and self._sign == 1 - and self._state < dt_util.utcnow() - ): - # if the date is supposed to be in the future but we're - # already past it, set state to None. - self._state = None - else: - seconds = self._sign * float(status[self._key][ATTR_VALUE]) - self._state = dt_util.utcnow() + timedelta(seconds=seconds) + elif self.device_class == SensorDeviceClass.TIMESTAMP: + if ATTR_VALUE not in status[self._key]: + self._state = None + elif ( + self._state is not None + and self._sign == 1 + and self._state < dt_util.utcnow() + ): + # if the date is supposed to be in the future but we're + # already past it, set state to None. + self._state = None else: - self._state = status[self._key].get(ATTR_VALUE) - if self._key == BSH_OPERATION_STATE: - # Value comes back as an enum, we only really care about the - # last part, so split it off - # https://developer.home-connect.com/docs/status/operation_state - self._state = self._state.split(".")[-1] + seconds = self._sign * float(status[self._key][ATTR_VALUE]) + self._state = dt_util.utcnow() + timedelta(seconds=seconds) + else: + self._state = status[self._key].get(ATTR_VALUE) + if self._key == BSH_OPERATION_STATE: + # Value comes back as an enum, we only really care about the + # last part, so split it off + # https://developer.home-connect.com/docs/status/operation_state + self._state = self._state.split(".")[-1] _LOGGER.debug("Updated, new state: %s", self._state) @property diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 55a40e7ba9d..0a41f9c7a99 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -12,9 +12,13 @@ "title": "Support for Python {current_python_version} is being removed", "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." }, - "integration_key_no_support": { - "title": "This integration does not support YAML configuration", - "description": "The {domain} integration does not support configuration via YAML file. You may not notice any obvious issues with the integration, but the configuration settings defined in YAML are not actually applied. \n\nTo resolve this: 1. Please remove this integration from your YAML configuration file.\n\n2. Restart Home Assistant." + "config_entry_only": { + "title": "The {domain} integration does not support YAML configuration", + "description": "The {domain} integration does not support configuration via YAML file. You may not notice any obvious issues with the integration, but any configuration settings defined in YAML are not actually applied.\n\nTo resolve this:\n\n1. If you've not already done so, [set up the integration]({add_integration}).\n\n2. Remove `{domain}:` from your YAML configuration file.\n\n3. Restart Home Assistant." + }, + "platform_only": { + "title": "The {domain} integration does not support YAML configuration under its own key", + "description": "The {domain} integration does not support configuration under its own key, it must be configured under its supported platforms.\n\nTo resolve this:\n\n1. Remove `{domain}:` from your YAML configuration file.\n\n2. Restart Home Assistant." } }, "system_health": { diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 0f7ec704715..5f17069f5d5 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -32,6 +32,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: return usb_dev = entry.data["device"] + # The call to get_serial_by_id can be removed in HA Core 2024.1 dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) if not await multi_pan_addon_using_device(hass, dev_path): diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 7bc514d5615..5ac44f3f290 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -58,6 +58,7 @@ class HomeAssistantSkyConnectOptionsFlow(silabs_multiprotocol_addon.OptionsFlowH ) -> silabs_multiprotocol_addon.SerialPortSettings: """Return the radio serial port settings.""" usb_dev = self.config_entry.data["device"] + # The call to get_serial_by_id can be removed in HA Core 2024.1 dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) return silabs_multiprotocol_addon.SerialPortSettings( device=dev_path, diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 2b56a056821..514c218b101 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -28,7 +28,6 @@ from homeassistant.components.device_automation.trigger import ( ) from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN -from homeassistant.components.network import MDNS_TARGET_IP from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -168,7 +167,9 @@ BRIDGE_SCHEMA = vol.All( ), vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), - vol.Optional(CONF_ADVERTISE_IP): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_ADVERTISE_IP): vol.All( + cv.ensure_list, [ipaddress.ip_address], [cv.string] + ), vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, vol.Optional(CONF_DEVICES): cv.ensure_list, @@ -303,9 +304,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # ip_address and advertise_ip are yaml only ip_address = conf.get(CONF_IP_ADDRESS, [None]) - advertise_ip = conf.get( - CONF_ADVERTISE_IP, await network.async_get_source_ip(hass, MDNS_TARGET_IP) - ) + advertise_ips: list[str] = conf.get( + CONF_ADVERTISE_IP + ) or await network.async_get_announce_addresses(hass) + # exclude_accessory_mode is only used for config flow # to indicate that the config entry was setup after # we started creating config entries for entities that @@ -331,7 +333,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: exclude_accessory_mode, entity_config, homekit_mode, - advertise_ip, + advertise_ips, entry.entry_id, entry.title, devices=devices, @@ -508,7 +510,7 @@ class HomeKit: exclude_accessory_mode: bool, entity_config: dict, homekit_mode: str, - advertise_ip: str | None, + advertise_ips: list[str], entry_id: str, entry_title: str, devices: list[str] | None = None, @@ -521,7 +523,7 @@ class HomeKit: self._filter = entity_filter self._config = entity_config self._exclude_accessory_mode = exclude_accessory_mode - self._advertise_ip = advertise_ip + self._advertise_ips = advertise_ips self._entry_id = entry_id self._entry_title = entry_title self._homekit_mode = homekit_mode @@ -547,7 +549,7 @@ class HomeKit: address=self._ip_address, port=self._port, persist_file=persist_file, - advertised_address=self._advertise_ip, + advertised_address=self._advertise_ips, async_zeroconf_instance=async_zeroconf_instance, zeroconf_server=f"{uuid}-hap.local.", loader=get_loader(), diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index a2e3f8487c6..00168ef3898 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -281,7 +281,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] display_name=cleanup_name_for_homekit(name), aid=aid, iid_manager=HomeIIDManager(driver.iid_storage), - *args, + *args, # noqa: B026 **kwargs, ) self.config = config or {} diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index eb2cd5d34ad..ee737e01ff4 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -47,10 +47,12 @@ class DeviceTriggerAccessory(HomeAccessory): type_: str = trigger["type"] subtype: str | None = trigger.get("subtype") unique_id = f'{type_}-{subtype or ""}' - if (entity_id := trigger.get("entity_id")) and ( - entry := ent_reg.async_get(entity_id) + entity_id: str | None = None + 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)}" + entity_id = entry.entity_id trigger_name_parts = [] if entity_id and (state := self.hass.states.get(entity_id)): trigger_name_parts.append(state.name) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index ecd8113a2bb..ed9b8ca4622 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -16,16 +16,18 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_IDENTIFIERS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .config_flow import normalize_hkid from .connection import HKDevice -from .const import KNOWN_DEVICES +from .const import DOMAIN, KNOWN_DEVICES from .utils import async_get_controller _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a HomeKit connection on a config entry.""" diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index db85dbda3d5..b937e7f2e0b 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -272,7 +272,7 @@ class HKDevice: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), - name=f"HomeKit Controller {self.unique_id} BLE availability " + name=f"HomeKit Device {self.unique_id} BLE availability " "check poll", ) ) @@ -291,7 +291,7 @@ class HKDevice: self.hass, self.async_request_update, self.pairing.poll_interval, - name=f"HomeKit Controller {self.unique_id} availability check poll", + name=f"HomeKit Device {self.unique_id} availability check poll", ) ) @@ -714,7 +714,7 @@ class HKDevice: if not self._polling_lock_warned: _LOGGER.warning( ( - "HomeKit controller update skipped as previous poll still in" + "HomeKit device update skipped as previous poll still in" " flight: %s" ), self.unique_id, @@ -725,7 +725,7 @@ class HKDevice: if self._polling_lock_warned: _LOGGER.info( ( - "HomeKit controller no longer detecting back pressure - not" + "HomeKit device no longer detecting back pressure - not" " skipping poll: %s" ), self.unique_id, @@ -733,7 +733,7 @@ class HKDevice: self._polling_lock_warned = False async with self._polling_lock: - _LOGGER.debug("Starting HomeKit controller update: %s", self.unique_id) + _LOGGER.debug("Starting HomeKit device update: %s", self.unique_id) try: new_values_dict = await self.get_characteristics( @@ -755,7 +755,7 @@ class HKDevice: self._poll_failures = 0 self.process_new_events(new_values_dict) - _LOGGER.debug("Finished HomeKit controller update: %s", self.unique_id) + _LOGGER.debug("Finished HomeKit device update: %s", self.unique_id) def process_new_events( self, new_values_dict: dict[tuple[int, int], dict[str, Any]] diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index e396b3c9c97..cd2cf4022e7 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -73,6 +73,11 @@ class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD ) + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) + @property def mode(self) -> str | None: """Return the current mode, e.g., home, auto, baby. @@ -177,6 +182,11 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD ) + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) + @property def mode(self) -> str | None: """Return the current mode, e.g., home, auto, baby. diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 19167e762e9..d0a88bf8249 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -1,6 +1,6 @@ { "domain": "homekit_controller", - "name": "HomeKit Controller", + "name": "HomeKit Device", "after_dependencies": ["thread"], "bluetooth": [ { diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 2291f66d88a..7420ef7f3f9 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -1,18 +1,18 @@ { - "title": "HomeKit Controller", + "title": "HomeKit Device", "config": { "flow_title": "{name} ({category})", "step": { "user": { "title": "Device selection", - "description": "HomeKit Controller communicates over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Select the device you want to pair with:", + "description": "HomeKit Device communicates over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Select the device you want to pair with:", "data": { "device": "Device" } }, "pair": { "title": "Pair with a device via HomeKit Accessory Protocol", - "description": "HomeKit Controller communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", + "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging.", "data": { "pairing_code": "Pairing Code", "allow_insecure_setup_codes": "Allow pairing with insecure setup codes." diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/generic_entity.py index a5296675292..7a6e7c18e13 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -164,7 +164,7 @@ class HomematicipGenericEntity(Entity): else: # Remove from entity registry. # Only relevant for entities that do not belong to a device. - if entity_id := self.registry_entry.entity_id: + if entity_id := self.registry_entry.entity_id: # noqa: PLR5501 entity_registry = er.async_get(self.hass) if entity_id in entity_registry.entities: entity_registry.async_remove(entity_id) @@ -185,9 +185,8 @@ class HomematicipGenericEntity(Entity): if hasattr(self._device, "functionalChannels"): if self._is_multi_channel: name = self._device.functionalChannels[self._channel].label - else: - if len(self._device.functionalChannels) > 1: - name = self._device.functionalChannels[1].label + elif len(self._device.functionalChannels) > 1: + name = self._device.functionalChannels[1].label # Use device label, if name is not defined by channel label. if not name: diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 770687ef50d..65919033801 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from homematicip.aio.device import ( + AsyncBrandSwitch2, AsyncBrandSwitchMeasuring, AsyncDinRailSwitch, AsyncDinRailSwitch4, @@ -77,6 +78,9 @@ async def async_setup_entry( elif isinstance(device, AsyncPrintedCircuitBoardSwitch2): for channel in range(1, 3): entities.append(HomematicipMultiSwitch(hap, device, channel=channel)) + elif isinstance(device, AsyncBrandSwitch2): + for channel in range(1, 3): + entities.append(HomematicipMultiSwitch(hap, device, channel=channel)) for group in hap.home.groups: if isinstance(group, (AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup)): diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 665406499e1..96fe1b157f8 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -1,6 +1,6 @@ """Support for HomeWizard buttons.""" -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -25,8 +25,7 @@ class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): """Representation of a identify button.""" _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_icon = "mdi:magnify" - _attr_name = "Identify" + _attr_device_class = ButtonDeviceClass.IDENTIFY def __init__( self, diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 0451aed9739..d51d180edb1 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -29,7 +29,7 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:lightbulb-on" - _attr_name = "Status light brightness" + _attr_translation_key = "status_light_brightness" _attr_native_unit_of_measurement = PERCENTAGE def __init__( @@ -55,4 +55,5 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): or self.coordinator.data.state.brightness is None ): return None - return round(self.coordinator.data.state.brightness * (100 / 255)) + brightness: float = self.coordinator.data.state.brightness + return round(brightness * (100 / 255)) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 6462c281346..d8cc72ce45e 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -38,6 +38,7 @@ PARALLEL_UPDATES = 1 class HomeWizardEntityDescriptionMixin: """Mixin values for HomeWizard entities.""" + has_fn: Callable[[Data], bool] value_fn: Callable[[Data], float | int | str | None] @@ -47,40 +48,47 @@ class HomeWizardSensorEntityDescription( ): """Class describing HomeWizard sensor entities.""" + enabled_fn: Callable[[Data], bool] = lambda data: True + SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="smr_version", - name="DSMR version", + translation_key="dsmr_version", icon="mdi:counter", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.smr_version is not None, value_fn=lambda data: data.smr_version, ), HomeWizardSensorEntityDescription( key="meter_model", - name="Smart meter model", + translation_key="meter_model", icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.meter_model is not None, value_fn=lambda data: data.meter_model, ), HomeWizardSensorEntityDescription( key="unique_meter_id", - name="Smart meter identifier", + translation_key="unique_meter_id", icon="mdi:alphabetical-variant", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.unique_meter_id is not None, value_fn=lambda data: data.unique_meter_id, ), HomeWizardSensorEntityDescription( key="wifi_ssid", - name="Wi-Fi SSID", + translation_key="wifi_ssid", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.wifi_ssid is not None, value_fn=lambda data: data.wifi_ssid, ), HomeWizardSensorEntityDescription( key="active_tariff", - name="Active tariff", + translation_key="active_tariff", icon="mdi:calendar-clock", + has_fn=lambda data: data.active_tariff is not None, value_fn=lambda data: ( None if data.active_tariff is None else str(data.active_tariff) ), @@ -89,290 +97,331 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="wifi_strength", - name="Wi-Fi strength", + translation_key="wifi_strength", icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + has_fn=lambda data: data.wifi_strength is not None, value_fn=lambda data: data.wifi_strength, ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", - name="Total power import", + translation_key="total_power_import_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_import_kwh, + has_fn=lambda data: data.total_power_import_kwh is not None, + value_fn=lambda data: data.total_power_import_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", - name="Total power import T1", + translation_key="total_power_import_t1_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_import_t1_kwh, + has_fn=lambda data: data.total_power_import_t1_kwh is not None, + value_fn=lambda data: data.total_power_import_t1_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", - name="Total power import T2", + translation_key="total_power_import_t2_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_import_t2_kwh, + has_fn=lambda data: data.total_power_import_t2_kwh is not None, + value_fn=lambda data: data.total_power_import_t2_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", - name="Total power import T3", + translation_key="total_power_import_t3_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_import_t3_kwh, + has_fn=lambda data: data.total_power_import_t3_kwh is not None, + value_fn=lambda data: data.total_power_import_t3_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", - name="Total power import T4", + translation_key="total_power_import_t4_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_import_t4_kwh, + has_fn=lambda data: data.total_power_import_t4_kwh is not None, + value_fn=lambda data: data.total_power_import_t4_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", - name="Total power export", + translation_key="total_power_export_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_export_kwh, + has_fn=lambda data: data.total_power_export_kwh is not None, + enabled_fn=lambda data: data.total_power_export_kwh != 0, + value_fn=lambda data: data.total_power_export_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", - name="Total power export T1", + translation_key="total_power_export_t1_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_export_t1_kwh, + has_fn=lambda data: data.total_power_export_t1_kwh is not None, + enabled_fn=lambda data: data.total_power_export_t1_kwh != 0, + value_fn=lambda data: data.total_power_export_t1_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", - name="Total power export T2", + translation_key="total_power_export_t2_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_export_t2_kwh, + has_fn=lambda data: data.total_power_export_t2_kwh is not None, + enabled_fn=lambda data: data.total_power_export_t2_kwh != 0, + value_fn=lambda data: data.total_power_export_t2_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", - name="Total power export T3", + translation_key="total_power_export_t3_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_export_t3_kwh, + has_fn=lambda data: data.total_power_export_t3_kwh is not None, + enabled_fn=lambda data: data.total_power_export_t3_kwh != 0, + value_fn=lambda data: data.total_power_export_t3_kwh or None, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", - name="Total power export T4", + translation_key="total_power_export_t4_kwh", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_power_export_t4_kwh, + has_fn=lambda data: data.total_power_export_t4_kwh is not None, + enabled_fn=lambda data: data.total_power_export_t4_kwh != 0, + value_fn=lambda data: data.total_power_export_t4_kwh or None, ), HomeWizardSensorEntityDescription( key="active_power_w", - name="Active power", + translation_key="active_power_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + has_fn=lambda data: data.active_power_w is not None, value_fn=lambda data: data.active_power_w, ), HomeWizardSensorEntityDescription( key="active_power_l1_w", - name="Active power L1", + translation_key="active_power_l1_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + has_fn=lambda data: data.active_power_l1_w is not None, value_fn=lambda data: data.active_power_l1_w, ), HomeWizardSensorEntityDescription( key="active_power_l2_w", - name="Active power L2", + translation_key="active_power_l2_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + has_fn=lambda data: data.active_power_l2_w is not None, value_fn=lambda data: data.active_power_l2_w, ), HomeWizardSensorEntityDescription( key="active_power_l3_w", - name="Active power L3", + translation_key="active_power_l3_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + has_fn=lambda data: data.active_power_l3_w is not None, value_fn=lambda data: data.active_power_l3_w, ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", - name="Active voltage L1", + translation_key="active_voltage_l1_v", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + has_fn=lambda data: data.active_voltage_l1_v is not None, value_fn=lambda data: data.active_voltage_l1_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", - name="Active voltage L2", + translation_key="active_voltage_l2_v", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + has_fn=lambda data: data.active_voltage_l2_v is not None, value_fn=lambda data: data.active_voltage_l2_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", - name="Active voltage L3", + translation_key="active_voltage_l3_v", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + has_fn=lambda data: data.active_voltage_l3_v is not None, value_fn=lambda data: data.active_voltage_l3_v, ), HomeWizardSensorEntityDescription( key="active_current_l1_a", - name="Active current L1", + translation_key="active_current_l1_a", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + has_fn=lambda data: data.active_current_l1_a is not None, value_fn=lambda data: data.active_current_l1_a, ), HomeWizardSensorEntityDescription( key="active_current_l2_a", - name="Active current L2", + translation_key="active_current_l2_a", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + has_fn=lambda data: data.active_current_l2_a is not None, value_fn=lambda data: data.active_current_l2_a, ), HomeWizardSensorEntityDescription( key="active_current_l3_a", - name="Active current L3", + translation_key="active_current_l3_a", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + has_fn=lambda data: data.active_current_l3_a is not None, value_fn=lambda data: data.active_current_l3_a, ), HomeWizardSensorEntityDescription( key="active_frequency_hz", - name="Active frequency", + translation_key="active_frequency_hz", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + has_fn=lambda data: data.active_frequency_hz is not None, value_fn=lambda data: data.active_frequency_hz, ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", - name="Voltage sags detected L1", + translation_key="voltage_sag_l1_count", icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.voltage_sag_l1_count is not None, value_fn=lambda data: data.voltage_sag_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l2_count", - name="Voltage sags detected L2", + translation_key="voltage_sag_l2_count", icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.voltage_sag_l2_count is not None, value_fn=lambda data: data.voltage_sag_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l3_count", - name="Voltage sags detected L3", + translation_key="voltage_sag_l3_count", icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.voltage_sag_l3_count is not None, value_fn=lambda data: data.voltage_sag_l3_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l1_count", - name="Voltage swells detected L1", + translation_key="voltage_swell_l1_count", icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.voltage_swell_l1_count is not None, value_fn=lambda data: data.voltage_swell_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l2_count", - name="Voltage swells detected L2", + translation_key="voltage_swell_l2_count", icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.voltage_swell_l2_count is not None, value_fn=lambda data: data.voltage_swell_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l3_count", - name="Voltage swells detected L3", + translation_key="voltage_swell_l3_count", icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.voltage_swell_l3_count is not None, value_fn=lambda data: data.voltage_swell_l3_count, ), HomeWizardSensorEntityDescription( key="any_power_fail_count", - name="Power failures detected", + translation_key="any_power_fail_count", icon="mdi:transmission-tower-off", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.any_power_fail_count is not None, value_fn=lambda data: data.any_power_fail_count, ), HomeWizardSensorEntityDescription( key="long_power_fail_count", - name="Long power failures detected", + translation_key="long_power_fail_count", icon="mdi:transmission-tower-off", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.long_power_fail_count is not None, value_fn=lambda data: data.long_power_fail_count, ), HomeWizardSensorEntityDescription( key="active_power_average_w", - name="Active average demand", + translation_key="active_power_average_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + has_fn=lambda data: data.active_power_average_w is not None, value_fn=lambda data: data.active_power_average_w, ), HomeWizardSensorEntityDescription( key="monthly_power_peak_w", - name="Peak demand current month", + translation_key="monthly_power_peak_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + has_fn=lambda data: data.monthly_power_peak_w is not None, value_fn=lambda data: data.monthly_power_peak_w, ), HomeWizardSensorEntityDescription( key="total_gas_m3", - name="Total gas", + translation_key="total_gas_m3", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_gas_m3, + has_fn=lambda data: data.total_gas_m3 is not None, + value_fn=lambda data: data.total_gas_m3 or None, ), HomeWizardSensorEntityDescription( key="gas_unique_id", - name="Gas meter identifier", + translation_key="gas_unique_id", icon="mdi:alphabetical-variant", entity_category=EntityCategory.DIAGNOSTIC, + has_fn=lambda data: data.gas_unique_id is not None, value_fn=lambda data: data.gas_unique_id, ), HomeWizardSensorEntityDescription( key="active_liter_lpm", - name="Active water usage", + translation_key="active_liter_lpm", native_unit_of_measurement="l/min", icon="mdi:water", state_class=SensorStateClass.MEASUREMENT, + has_fn=lambda data: data.active_liter_lpm is not None, value_fn=lambda data: data.active_liter_lpm, ), HomeWizardSensorEntityDescription( key="total_liter_m3", - name="Total water usage", + translation_key="total_liter_m3", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, icon="mdi:gauge", device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.total_liter_m3, + has_fn=lambda data: data.total_liter_m3 is not None, + value_fn=lambda data: data.total_liter_m3 or None, ), ) @@ -386,7 +435,7 @@ async def async_setup_entry( async_add_entities( HomeWizardSensorEntity(coordinator, entry, description) for description in SENSORS - if description.value_fn(coordinator.data.data) is not None + if description.has_fn(coordinator.data.data) ) @@ -405,20 +454,7 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{entry.unique_id}_{description.key}" - - # Special case for export, not everyone has solar panels - # The chance that 'export' is non-zero when you have solar panels is nil - if ( - description.key - in [ - "total_power_export_kwh", - "total_power_export_t1_kwh", - "total_power_export_t2_kwh", - "total_power_export_t3_kwh", - "total_power_export_t4_kwh", - ] - and self.native_value == 0 - ): + if not description.enabled_fn(self.coordinator.data.data): self._attr_entity_registry_enabled_default = False @property diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 53eeafe99e1..7bb4b16c710 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -27,5 +27,145 @@ "unknown_error": "[%key:common::config_flow::error::unknown%]", "reauth_successful": "Enabling API was successful" } + }, + "entity": { + "number": { + "status_light_brightness": { + "name": "Status light brightness" + } + }, + "sensor": { + "dsmr_version": { + "name": "DSMR version" + }, + "meter_model": { + "name": "Smart meter model" + }, + "unique_meter_id": { + "name": "Smart meter identifier" + }, + "wifi_ssid": { + "name": "Wi-Fi SSID" + }, + "active_tariff": { + "name": "Active tariff" + }, + "wifi_strength": { + "name": "Wi-Fi strength" + }, + "total_power_import_kwh": { + "name": "Total power import" + }, + "total_power_import_t1_kwh": { + "name": "Total power import tariff 1" + }, + "total_power_import_t2_kwh": { + "name": "Total power import tariff 2" + }, + "total_power_import_t3_kwh": { + "name": "Total power import tariff 3" + }, + "total_power_import_t4_kwh": { + "name": "Total power import tariff 4" + }, + "total_power_export_kwh": { + "name": "Total power export" + }, + "total_power_export_t1_kwh": { + "name": "Total power export tariff 1" + }, + "total_power_export_t2_kwh": { + "name": "Total power export tariff 2" + }, + "total_power_export_t3_kwh": { + "name": "Total power export tariff 3" + }, + "total_power_export_t4_kwh": { + "name": "Total power export tariff 4" + }, + "active_power_w": { + "name": "Active power" + }, + "active_power_l1_w": { + "name": "Active power phase 1" + }, + "active_power_l2_w": { + "name": "Active power phase 2" + }, + "active_power_l3_w": { + "name": "Active power phase 3" + }, + "active_voltage_l1_v": { + "name": "Active voltage phase 1" + }, + "active_voltage_l2_v": { + "name": "Active voltage phase 2" + }, + "active_voltage_l3_v": { + "name": "Active voltage phase 3" + }, + "active_current_l1_a": { + "name": "Active current phase 1" + }, + "active_current_l2_a": { + "name": "Active current phase 2" + }, + "active_current_l3_a": { + "name": "Active current phase 3" + }, + "active_frequency_hz": { + "name": "Active frequency" + }, + "voltage_sag_l1_count": { + "name": "Voltage sags detected phase 1" + }, + "voltage_sag_l2_count": { + "name": "Voltage sags detected phase 2" + }, + "voltage_sag_l3_count": { + "name": "Voltage sags detected phase 3" + }, + "voltage_swell_l1_count": { + "name": "Voltage swells detected phase 1" + }, + "voltage_swell_l2_count": { + "name": "Voltage swells detected phase 2" + }, + "voltage_swell_l3_count": { + "name": "Voltage swells detected phase 3" + }, + "any_power_fail_count": { + "name": "Power failures detected" + }, + "long_power_fail_count": { + "name": "Long power failures detected" + }, + "active_power_average_w": { + "name": "Active average demand" + }, + "monthly_power_peak_w": { + "name": "Peak demand current month" + }, + "total_gas_m3": { + "name": "Total gas" + }, + "gas_unique_id": { + "name": "Gas meter identifier" + }, + "active_liter_lpm": { + "name": "Active water usage" + }, + "total_liter_m3": { + "name": "Total water usage" + } + }, + "switch": { + "switch_lock": { + "name": "Switch lock" + }, + "cloud_connection": { + "name": "Cloud connection" + } + } } } diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 1edb9e1e605..cddcabc841e 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -45,6 +45,7 @@ class HomeWizardSwitchEntityDescription( SWITCHES = [ HomeWizardSwitchEntityDescription( key="power_on", + name=None, device_class=SwitchDeviceClass.OUTLET, create_fn=lambda coordinator: coordinator.supports_state(), available_fn=lambda data: data.state is not None and not data.state.switch_lock, @@ -53,7 +54,7 @@ SWITCHES = [ ), HomeWizardSwitchEntityDescription( key="switch_lock", - name="Switch lock", + translation_key="switch_lock", entity_category=EntityCategory.CONFIG, icon="mdi:lock", icon_off="mdi:lock-open", @@ -64,7 +65,7 @@ SWITCHES = [ ), HomeWizardSwitchEntityDescription( key="cloud_connection", - name="Cloud connection", + translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, icon="mdi:cloud", icon_off="mdi:cloud-off-outline", diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index dd33da56297..db31baa53a6 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -101,6 +101,7 @@ class HoneywellUSThermostat(ClimateEntity): """Representation of a Honeywell US Thermostat.""" _attr_has_entity_name = True + _attr_name = None def __init__( self, diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 8f3b66ddeac..16b07e91446 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["aiosomecomfort==0.0.14"] + "requirements": ["AIOSomecomfort==0.0.14"] } diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 4d6ea4d9528..ae4ede2a079 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -48,7 +48,7 @@ class HoneywellSensorEntityDescription( SENSOR_TYPES: tuple[HoneywellSensorEntityDescription, ...] = ( HoneywellSensorEntityDescription( key=TEMPERATURE_STATUS_KEY, - name="Outdoor temperature", + translation_key="outdoor_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda device: device.outdoor_temperature, @@ -56,7 +56,7 @@ SENSOR_TYPES: tuple[HoneywellSensorEntityDescription, ...] = ( ), HoneywellSensorEntityDescription( key=HUMIDITY_STATUS_KEY, - name="Outdoor humidity", + translation_key="outdoor_humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda device: device.outdoor_humidity, diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 73986920b8a..b0cd2a52c1b 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -31,5 +31,15 @@ } } } + }, + "entity": { + "sensor": { + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "outdoor_humidity": { + "name": "Outdoor humidity" + } + } } } diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index bce425adbdb..dec1b9485b6 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp_cors==0.7.0"] + "requirements": ["aiohttp-cors==0.7.0"] } diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index f6c3b69ddeb..6d7b0b9bb11 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -8,7 +8,6 @@ from urllib.parse import urlparse from huawei_lte_api.Client import Client from huawei_lte_api.Connection import Connection -from huawei_lte_api.Session import GetResponseType from huawei_lte_api.exceptions import ( LoginErrorPasswordWrongException, LoginErrorUsernamePasswordOverrunException, @@ -16,6 +15,7 @@ from huawei_lte_api.exceptions import ( LoginErrorUsernameWrongException, ResponseErrorException, ) +from huawei_lte_api.Session import GetResponseType from requests.exceptions import Timeout from url_normalize import url_normalize import voluptuous as vol diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index f63cc4aac39..133b569c751 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -117,6 +117,10 @@ class HuaweiSensorGroup: class HuaweiSensorEntityDescription(SensorEntityDescription): """Class describing Huawei LTE sensor entities.""" + # HuaweiLteSensor does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + format_fn: Callable[[str], tuple[StateType, str | None]] = format_default icon_fn: Callable[[StateType], str] | None = None device_class_fn: Callable[[StateType], SensorDeviceClass | None] | None = None diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 369c6eba075..8bc86d423a1 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -58,7 +58,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): SENSORS_INFO = [ HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power", + translation_key="current_power", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -67,7 +67,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power In Peak", + translation_key="current_power_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -76,7 +76,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power In Off Peak", + translation_key="current_power_off_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -85,7 +85,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power Out Peak", + translation_key="current_power_out_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -94,7 +94,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Power Out Off Peak", + translation_key="current_power_out_off_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -103,7 +103,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Consumption Peak Today", + translation_key="energy_consumption_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_IN, @@ -113,7 +113,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Consumption Off Peak Today", + translation_key="energy_consumption_off_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_IN_LOW, @@ -123,7 +123,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Production Peak Today", + translation_key="energy_production_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_OUT, @@ -133,7 +133,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Production Off Peak Today", + translation_key="energy_production_off_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_OUT_LOW, @@ -143,7 +143,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy Today", + translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, @@ -153,7 +153,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy This Week", + translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, @@ -163,7 +163,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy This Month", + translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, @@ -173,7 +173,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Energy This Year", + translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.MEASUREMENT, @@ -183,7 +183,7 @@ SENSORS_INFO = [ icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Current Gas", + translation_key="current_gas", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, sensor_type=SENSOR_TYPE_RATE, state_class=SensorStateClass.MEASUREMENT, @@ -192,7 +192,7 @@ SENSORS_INFO = [ precision=1, ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Gas Today", + translation_key="gas_today", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, @@ -202,7 +202,7 @@ SENSORS_INFO = [ precision=1, ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Gas This Week", + translation_key="gas_week", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, @@ -212,7 +212,7 @@ SENSORS_INFO = [ precision=1, ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Gas This Month", + translation_key="gas_month", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, @@ -222,7 +222,7 @@ SENSORS_INFO = [ precision=1, ), HuisbaasjeSensorEntityDescription( - name="Huisbaasje Gas This Year", + translation_key="gas_year", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, @@ -257,6 +257,7 @@ class HuisbaasjeSensor( """Defines a Huisbaasje sensor.""" entity_description: HuisbaasjeSensorEntityDescription + _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/huisbaasje/strings.json b/homeassistant/components/huisbaasje/strings.json index 169b9a0e901..de112f7519f 100644 --- a/homeassistant/components/huisbaasje/strings.json +++ b/homeassistant/components/huisbaasje/strings.json @@ -16,5 +16,63 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "current_power": { + "name": "Current power" + }, + "current_power_peak": { + "name": "Current power in peak" + }, + "current_power_off_peak": { + "name": "Current power in off peak" + }, + "current_power_out_peak": { + "name": "Current power out peak" + }, + "current_power_out_off_peak": { + "name": "Current power out off peak" + }, + "energy_consumption_peak_today": { + "name": "Energy consumption peak today" + }, + "energy_consumption_off_peak_today": { + "name": "Energy consumption off peak today" + }, + "energy_production_peak_today": { + "name": "Energy production peak today" + }, + "energy_production_off_peak_today": { + "name": "Energy production off peak today" + }, + "energy_today": { + "name": "Energy today" + }, + "energy_week": { + "name": "Energy this week" + }, + "energy_month": { + "name": "Energy this month" + }, + "energy_year": { + "name": "Energy this year" + }, + "current_gas": { + "name": "Current gas" + }, + "gas_today": { + "name": "Gas today" + }, + "gas_week": { + "name": "Gas this week" + }, + "gas_month": { + "name": "Gas this month" + }, + "gas_year": { + "name": "Gas this year" + } + } } } diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 0bc7e242d55..79effa6f0c2 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -29,7 +29,9 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from .const import ( # noqa: F401 + ATTR_ACTION, ATTR_AVAILABLE_MODES, + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, @@ -44,6 +46,7 @@ from .const import ( # noqa: F401 SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, SUPPORT_MODES, + HumidifierAction, HumidifierEntityFeature, ) @@ -132,7 +135,9 @@ class HumidifierEntity(ToggleEntity): """Base class for humidifier entities.""" entity_description: HumidifierEntityDescription + _attr_action: HumidifierAction | None = None _attr_available_modes: list[str] | None + _attr_current_humidity: int | None = None _attr_device_class: HumidifierDeviceClass | None _attr_max_humidity: int = DEFAULT_MAX_HUMIDITY _attr_min_humidity: int = DEFAULT_MIN_HUMIDITY @@ -168,6 +173,12 @@ class HumidifierEntity(ToggleEntity): """Return the optional state attributes.""" data: dict[str, int | str | None] = {} + if self.action is not None: + data[ATTR_ACTION] = self.action if self.is_on else HumidifierAction.OFF + + if self.current_humidity is not None: + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + if self.target_humidity is not None: data[ATTR_HUMIDITY] = self.target_humidity @@ -176,6 +187,16 @@ class HumidifierEntity(ToggleEntity): return data + @property + def action(self) -> HumidifierAction | None: + """Return the current action.""" + return self._attr_action + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self._attr_current_humidity + @property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 1f802f7fa36..35601cf2b1f 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,6 +1,8 @@ """Provides the constants needed for component.""" from enum import IntFlag +from homeassistant.backports.enum import StrEnum + MODE_NORMAL = "normal" MODE_ECO = "eco" MODE_AWAY = "away" @@ -11,7 +13,19 @@ MODE_SLEEP = "sleep" MODE_AUTO = "auto" MODE_BABY = "baby" + +class HumidifierAction(StrEnum): + """Actions for humidifier devices.""" + + HUMIDIFYING = "humidifying" + DRYING = "drying" + IDLE = "idle" + OFF = "off" + + +ATTR_ACTION = "action" ATTR_AVAILABLE_MODES = "available_modes" +ATTR_CURRENT_HUMIDITY = "current_humidity" ATTR_HUMIDITY = "humidity" ATTR_MAX_HUMIDITY = "max_humidity" ATTR_MIN_HUMIDITY = "min_humidity" diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 1c027ba22e6..f1f25101e93 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -3,7 +3,11 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, @@ -26,7 +30,7 @@ from . import DOMAIN, const SET_HUMIDITY_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): "set_humidity", - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(const.ATTR_HUMIDITY): vol.Coerce(int), } ) @@ -34,14 +38,21 @@ SET_HUMIDITY_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( SET_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): "set_mode", - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(ATTR_MODE): cv.string, } ) ONOFF_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) -ACTION_SCHEMA = vol.Any(SET_HUMIDITY_SCHEMA, SET_MODE_SCHEMA, ONOFF_SCHEMA) +_ACTION_SCHEMA = vol.Any(SET_HUMIDITY_SCHEMA, SET_MODE_SCHEMA, ONOFF_SCHEMA) + + +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( @@ -61,7 +72,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } actions.append({**base_action, CONF_TYPE: "set_humidity"}) @@ -108,9 +119,11 @@ async def async_get_action_capabilities( fields[vol.Required(const.ATTR_HUMIDITY)] = vol.Coerce(int) elif action_type == "set_mode": try: + entry = async_get_entity_registry_entry_or_raise( + hass, config[CONF_ENTITY_ID] + ) available_modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_AVAILABLE_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or [] ) except HomeAssistantError: available_modes = [] diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index 05812e35a36..c2c0378a746 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -3,7 +3,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, + toggle_entity, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, @@ -32,7 +35,7 @@ TOGGLE_CONDITION = toggle_entity.CONDITION_SCHEMA.extend( MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "is_mode", vol.Required(ATTR_MODE): str, } @@ -61,7 +64,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "is_mode", } ) @@ -79,11 +82,15 @@ def async_condition_from_config( else: return toggle_entity.async_condition_from_config(hass, config) + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - state = hass.states.get(config[ATTR_ENTITY_ID]) return ( - state is not None and state.attributes.get(attribute) == config[attribute] + entity_id is not None + and (state := hass.states.get(entity_id)) is not None + and state.attributes.get(attribute) == config[attribute] ) return test_is_state @@ -99,9 +106,11 @@ async def async_get_condition_capabilities( if condition_type == "is_mode": try: + entry = async_get_entity_registry_entry_or_raise( + hass, config[CONF_ENTITY_ID] + ) modes = ( - get_capability(hass, config[ATTR_ENTITY_ID], const.ATTR_AVAILABLE_MODES) - or [] + get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or [] ) except HomeAssistantError: modes = [] diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 5fbb248a8bc..0d689a35318 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -26,14 +26,27 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from . import ATTR_CURRENT_HUMIDITY, DOMAIN # mypy: disallow-any-generics +CURRENT_TRIGGER_SCHEMA = vol.All( + DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, + vol.Required(CONF_TYPE): "current_humidity_changed", + vol.Optional(CONF_BELOW): vol.Any(vol.Coerce(float)), + vol.Optional(CONF_ABOVE): vol.Any(vol.Coerce(float)), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + HUMIDIFIER_TRIGGER_SCHEMA = vol.All( DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "target_humidity_changed", vol.Optional(CONF_BELOW): vol.Any(vol.Coerce(int)), vol.Optional(CONF_ABOVE): vol.Any(vol.Coerce(int)), @@ -45,6 +58,7 @@ HUMIDIFIER_TRIGGER_SCHEMA = vol.All( TRIGGER_SCHEMA = vol.All( vol.Any( + CURRENT_TRIGGER_SCHEMA, HUMIDIFIER_TRIGGER_SCHEMA, toggle_entity.TRIGGER_SCHEMA, ), @@ -64,15 +78,31 @@ async def async_get_triggers( if entry.domain != DOMAIN: continue + state = hass.states.get(entry.entity_id) + + # Add triggers for each entity that belongs to this integration + base_trigger = { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.id, + } + triggers.append( { - CONF_PLATFORM: "device", - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + **base_trigger, CONF_TYPE: "target_humidity_changed", } ) + + if state and ATTR_CURRENT_HUMIDITY in state.attributes: + triggers.append( + { + **base_trigger, + CONF_TYPE: "current_humidity_changed", + } + ) + return triggers @@ -83,7 +113,10 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - if config[CONF_TYPE] == "target_humidity_changed": + if (trigger_type := config[CONF_TYPE]) in { + "current_humidity_changed", + "target_humidity_changed", + }: numeric_state_config = { numeric_state_trigger.CONF_PLATFORM: "numeric_state", numeric_state_trigger.CONF_ENTITY_ID: config[CONF_ENTITY_ID], @@ -91,6 +124,14 @@ async def async_attach_trigger( "{{ state.attributes.humidity }}" ), } + if trigger_type == "target_humidity_changed": + numeric_state_config[ + numeric_state_trigger.CONF_VALUE_TEMPLATE + ] = "{{ state.attributes.humidity }}" + else: # trigger_type == "current_humidity_changed" + numeric_state_config[ + numeric_state_trigger.CONF_VALUE_TEMPLATE + ] = "{{ state.attributes.current_humidity }}" if CONF_ABOVE in config: numeric_state_config[CONF_ABOVE] = config[CONF_ABOVE] @@ -115,7 +156,7 @@ async def async_get_trigger_capabilities( hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: """List trigger capabilities.""" - if config[CONF_TYPE] == "target_humidity_changed": + if config[CONF_TYPE] in {"current_humidity_changed", "target_humidity_changed"}: return { "extra_fields": vol.Schema( { diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 015b3c08e9a..3b4c0bf2dab 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -28,9 +28,21 @@ "on": "[%key:common::state::on%]" }, "state_attributes": { + "action": { + "name": "Action", + "state": { + "humidifying": "Humidifying", + "drying": "Drying", + "idle": "Idle", + "off": "Off" + } + }, "available_modes": { "name": "Available modes" }, + "current_humidity": { + "name": "Current humidity" + }, "humidity": { "name": "Target humidity" }, diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 85192e0b7e4..b36457324e1 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -70,7 +70,7 @@ SENSORS: Final = [ PowerviewSensorDescription( key="signal", name="Signal", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, + icon="mdi:signal", native_unit_of_measurement=PERCENTAGE, native_value_fn=lambda shade: round( shade.raw_data[ATTR_SIGNAL_STRENGTH] / ATTR_SIGNAL_STRENGTH_MAX * 100 diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 0669289c1bb..c58ae6e3931 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -58,16 +58,16 @@ class HVVDepartureSensor(SensorEntity): _attr_attribution = ATTRIBUTION _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_icon = ICON + _attr_translation_key = "departures" + _attr_has_entity_name = True + _attr_available = False def __init__(self, hass, config_entry, session, hub): """Initialize.""" self.config_entry = config_entry self.station_name = self.config_entry.data[CONF_STATION]["name"] - self._attr_extra_state_attributes = {} - self._attr_available = False - self._attr_has_entity_name = True - self._attr_name = "Departures" self._last_error = None + self._attr_extra_state_attributes = {} self.gti = hub.gti diff --git a/homeassistant/components/hvv_departures/strings.json b/homeassistant/components/hvv_departures/strings.json index 90b53bc0e64..8f9c06f53fb 100644 --- a/homeassistant/components/hvv_departures/strings.json +++ b/homeassistant/components/hvv_departures/strings.json @@ -43,5 +43,12 @@ } } } + }, + "entity": { + "sensor": { + "departures": { + "name": "Departures" + } + } } } diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 2489317a6a2..fc88c08b27a 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["hydrawiser"], - "requirements": ["hydrawiser==0.2"] + "requirements": ["Hydrawiser==0.2"] } diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 6c2842c190e..ea038b3b408 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -19,7 +19,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.typing import ConfigType from .const import ( CONF_INSTANCE_CLIENTS, @@ -104,12 +103,6 @@ async def async_create_connect_hyperion_client( return hyperion_client -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Hyperion component.""" - hass.data[DOMAIN] = {} - return True - - @callback def listen_for_instance_updates( hass: HomeAssistant, @@ -191,6 +184,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # We need 1 root client (to manage instances being removed/added) and then 1 client # per Hyperion server instance which is shared for all entities associated with # that instance. + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { CONF_ROOT_CLIENT: hyperion_client, CONF_INSTANCE_CLIENTS: {}, diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 97e97cd835d..52c4647f11c 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -447,7 +447,7 @@ class HyperionOptionsFlow(OptionsFlow): ) -> FlowResult: """Manage the options.""" - effects = {source: source for source in const.KEY_COMPONENTID_EXTERNAL_SOURCES} + effects = {} async with self._create_client() as hyperion_client: if not hyperion_client: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index e7e4e7f70a4..4585b8bedaa 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -21,8 +21,6 @@ HYPERION_MODEL_NAME = f"{HYPERION_MANUFACTURER_NAME}-NG" HYPERION_RELEASES_URL = "https://github.com/hyperion-project/hyperion.ng/releases" HYPERION_VERSION_WARN_CUTOFF = "2.0.0-alpha.9" -NAME_SUFFIX_HYPERION_LIGHT = "" -NAME_SUFFIX_HYPERION_PRIORITY_LIGHT = "Priority" NAME_SUFFIX_HYPERION_COMPONENT_SWITCH = "Component" NAME_SUFFIX_HYPERION_CAMERA = "" @@ -32,5 +30,4 @@ SIGNAL_ENTITY_REMOVE = f"{DOMAIN}_entity_remove_signal.{{}}" TYPE_HYPERION_CAMERA = "hyperion_camera" TYPE_HYPERION_LIGHT = "hyperion_light" -TYPE_HYPERION_PRIORITY_LIGHT = "hyperion_priority_light" TYPE_HYPERION_COMPONENT_SWITCH_BASE = "hyperion_component_switch" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index ba1dbfbafc2..e14c395315e 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -41,24 +41,19 @@ from .const import ( DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, - NAME_SUFFIX_HYPERION_LIGHT, - NAME_SUFFIX_HYPERION_PRIORITY_LIGHT, SIGNAL_ENTITY_REMOVE, TYPE_HYPERION_LIGHT, - TYPE_HYPERION_PRIORITY_LIGHT, ) _LOGGER = logging.getLogger(__name__) -COLOR_BLACK = color_util.COLORS["black"] - CONF_DEFAULT_COLOR = "default_color" CONF_HDMI_PRIORITY = "hdmi_priority" CONF_EFFECT_LIST = "effect_list" # As we want to preserve brightness control for effects (e.g. to reduce the -# brightness for V4L), we need to persist the effect that is in flight, so -# subsequent calls to turn_on will know the keep the effect enabled. +# brightness), we need to persist the effect that is in flight, so +# subsequent calls to turn_on will know to keep the effect enabled. # Unfortunately the Home Assistant UI does not easily expose a way to remove a # selected effect (there is no 'No Effect' option by default). Instead, we # create a new fake effect ("Solid") that is always selected by default for @@ -75,7 +70,6 @@ DEFAULT_EFFECT_LIST: list[str] = [] ICON_LIGHTBULB = "mdi:lightbulb" ICON_EFFECT = "mdi:lava-lamp" -ICON_EXTERNAL_SOURCE = "mdi:television-ambient-light" async def async_setup_entry( @@ -102,7 +96,6 @@ async def async_setup_entry( async_add_entities( [ HyperionLight(*args), - HyperionPriorityLight(*args), ] ) @@ -110,19 +103,18 @@ async def async_setup_entry( def instance_remove(instance_num: int) -> None: """Remove entities for an old Hyperion instance.""" assert server_id - for light_type in LIGHT_TYPES: - async_dispatcher_send( - hass, - SIGNAL_ENTITY_REMOVE.format( - get_hyperion_unique_id(server_id, instance_num, light_type) - ), - ) + async_dispatcher_send( + hass, + SIGNAL_ENTITY_REMOVE.format( + get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) + ), + ) listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) -class HyperionBaseLight(LightEntity): - """A Hyperion light base class.""" +class HyperionLight(LightEntity): + """A Hyperion light that acts as a client for the configured priority.""" _attr_color_mode = ColorMode.HS _attr_should_poll = False @@ -151,11 +143,6 @@ class HyperionBaseLight(LightEntity): self._effect: str = KEY_EFFECT_SOLID self._static_effect_list: list[str] = [KEY_EFFECT_SOLID] - if self._support_external_effects: - self._static_effect_list += [ - const.KEY_COMPONENTID_TO_NAME[component] - for component in const.KEY_COMPONENTID_EXTERNAL_SOURCES - ] self._effect_list: list[str] = self._static_effect_list[:] self._client_callbacks: Mapping[str, Callable[[dict[str, Any]], None]] = { @@ -168,11 +155,11 @@ class HyperionBaseLight(LightEntity): def _compute_unique_id(self, server_id: str, instance_num: int) -> str: """Compute a unique id for this instance.""" - raise NotImplementedError + return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) def _compute_name(self, instance_name: str) -> str: """Compute the name of the light.""" - raise NotImplementedError + return f"{instance_name}".strip() @property def entity_registry_enabled_default(self) -> bool: @@ -198,12 +185,6 @@ class HyperionBaseLight(LightEntity): def icon(self) -> str: """Return state specific icon.""" if self.is_on: - if ( - self.effect in const.KEY_COMPONENTID_FROM_NAME - and const.KEY_COMPONENTID_FROM_NAME[self.effect] - in const.KEY_COMPONENTID_EXTERNAL_SOURCES - ): - return ICON_EXTERNAL_SOURCE if self.effect != KEY_EFFECT_SOLID: return ICON_EFFECT return ICON_LIGHTBULB @@ -247,6 +228,11 @@ class HyperionBaseLight(LightEntity): } return self._options.get(key, defaults[key]) + @property + def is_on(self) -> bool: + """Return true if light is on. Light is considered on when there is a source at the configured HA priority.""" + return self._get_priority_entry_that_dictates_state() is not None + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" # == Get key parameters == @@ -279,55 +265,8 @@ class HyperionBaseLight(LightEntity): ): return - # == Set an external source - if ( - effect - and self._support_external_effects - and ( - effect in const.KEY_COMPONENTID_EXTERNAL_SOURCES - or effect in const.KEY_COMPONENTID_FROM_NAME - ) - ): - if effect in const.KEY_COMPONENTID_FROM_NAME: - component = const.KEY_COMPONENTID_FROM_NAME[effect] - else: - _LOGGER.warning( - ( - "Use of Hyperion effect '%s' is deprecated and will be removed " - "in a future release. Please use '%s' instead" - ), - effect, - const.KEY_COMPONENTID_TO_NAME[effect], - ) - component = effect - - # Clear any color/effect. - if not await self._client.async_send_clear( - **{const.KEY_PRIORITY: self._get_option(CONF_PRIORITY)} - ): - return - - # Turn off all external sources, except the intended. - for key in const.KEY_COMPONENTID_EXTERNAL_SOURCES: - if not await self._client.async_send_set_component( - **{ - const.KEY_COMPONENTSTATE: { - const.KEY_COMPONENT: key, - const.KEY_STATE: component == key, - } - } - ): - return - # == Set an effect - elif effect and effect != KEY_EFFECT_SOLID: - # This call should not be necessary, but without it there is no priorities-update issued: - # https://github.com/hyperion-project/hyperion.ng/issues/992 - if not await self._client.async_send_clear( - **{const.KEY_PRIORITY: self._get_option(CONF_PRIORITY)} - ): - return - + if effect and effect != KEY_EFFECT_SOLID: if not await self._client.async_send_set_effect( **{ const.KEY_PRIORITY: self._get_option(CONF_PRIORITY), @@ -336,16 +275,23 @@ class HyperionBaseLight(LightEntity): } ): return + # == Set a color - else: - if not await self._client.async_send_set_color( - **{ - const.KEY_PRIORITY: self._get_option(CONF_PRIORITY), - const.KEY_COLOR: rgb_color, - const.KEY_ORIGIN: DEFAULT_ORIGIN, - } - ): - return + elif not await self._client.async_send_set_color( + **{ + const.KEY_PRIORITY: self._get_option(CONF_PRIORITY), + const.KEY_COLOR: rgb_color, + const.KEY_ORIGIN: DEFAULT_ORIGIN, + } + ): + return + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light i.e. clear the configured priority.""" + if not await self._client.async_send_clear( + **{const.KEY_PRIORITY: self._get_option(CONF_PRIORITY)} + ): + return def _set_internal_state( self, @@ -384,24 +330,15 @@ class HyperionBaseLight(LightEntity): def _update_priorities(self, _: dict[str, Any] | None = None) -> None: """Update Hyperion priorities.""" priority = self._get_priority_entry_that_dictates_state() - if priority and self._allow_priority_update(priority): - componentid = priority.get(const.KEY_COMPONENTID) - if ( - self._support_external_effects - and componentid in const.KEY_COMPONENTID_EXTERNAL_SOURCES - and componentid in const.KEY_COMPONENTID_TO_NAME - ): - self._set_internal_state( - rgb_color=DEFAULT_COLOR, - effect=const.KEY_COMPONENTID_TO_NAME[componentid], - ) - elif componentid == const.KEY_COMPONENTID_EFFECT: + if priority: + component_id = priority.get(const.KEY_COMPONENTID) + if component_id == const.KEY_COMPONENTID_EFFECT: # Owner is the effect name. # See: https://docs.hyperion-project.org/en/json/ServerInfo.html#priorities self._set_internal_state( rgb_color=DEFAULT_COLOR, effect=priority[const.KEY_OWNER] ) - elif componentid == const.KEY_COMPONENTID_COLOR: + elif component_id == const.KEY_COMPONENTID_COLOR: self._set_internal_state( rgb_color=priority[const.KEY_VALUE][const.KEY_RGB], effect=KEY_EFFECT_SOLID, @@ -470,172 +407,10 @@ class HyperionBaseLight(LightEntity): """Cleanup prior to hass removal.""" self._client.remove_callbacks(self._client_callbacks) - @property - def _support_external_effects(self) -> bool: - """Whether or not to support setting external effects from the light entity.""" - return True - def _get_priority_entry_that_dictates_state(self) -> dict[str, Any] | None: """Get the relevant Hyperion priority entry to consider.""" - # Return the visible priority (whether or not it is the HA priority). - - # Explicit type specifier to ensure this works when the underlying (typed) - # library is installed along with the tests. Casts would trigger a - # redundant-cast warning in this case. - priority: dict[str, Any] | None = self._client.visible_priority - return priority - - def _allow_priority_update(self, priority: dict[str, Any] | None = None) -> bool: - """Determine whether to allow a priority to update internal state.""" - return True - - -class HyperionLight(HyperionBaseLight): - """A Hyperion light that acts in absolute (vs priority) manner. - - Light state is the absolute Hyperion component state (e.g. LED device on/off) rather - than color based at a particular priority, and the 'winning' priority determines - shown state rather than exclusively the HA priority. - """ - - def _compute_unique_id(self, server_id: str, instance_num: int) -> str: - """Compute a unique id for this instance.""" - return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) - - def _compute_name(self, instance_name: str) -> str: - """Compute the name of the light.""" - return f"{instance_name} {NAME_SUFFIX_HYPERION_LIGHT}".strip() - - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return ( - bool(self._client.is_on()) - and self._get_priority_entry_that_dictates_state() is not None - ) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the light.""" - # == Turn device on == - # Turn on both ALL (Hyperion itself) and LEDDEVICE. It would be - # preferable to enable LEDDEVICE after the settings (e.g. brightness, - # color, effect), but this is not possible due to: - # https://github.com/hyperion-project/hyperion.ng/issues/967 - if not bool(self._client.is_on()): - for component in ( - const.KEY_COMPONENTID_ALL, - const.KEY_COMPONENTID_LEDDEVICE, - ): - if not await self._client.async_send_set_component( - **{ - const.KEY_COMPONENTSTATE: { - const.KEY_COMPONENT: component, - const.KEY_STATE: True, - } - } - ): - return - - # Turn on the relevant Hyperion priority as usual. - await super().async_turn_on(**kwargs) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the light.""" - if not await self._client.async_send_set_component( - **{ - const.KEY_COMPONENTSTATE: { - const.KEY_COMPONENT: const.KEY_COMPONENTID_LEDDEVICE, - const.KEY_STATE: False, - } - } - ): - return - - -class HyperionPriorityLight(HyperionBaseLight): - """A Hyperion light that only acts on a single Hyperion priority.""" - - def _compute_unique_id(self, server_id: str, instance_num: int) -> str: - """Compute a unique id for this instance.""" - return get_hyperion_unique_id( - server_id, instance_num, TYPE_HYPERION_PRIORITY_LIGHT - ) - - def _compute_name(self, instance_name: str) -> str: - """Compute the name of the light.""" - return f"{instance_name} {NAME_SUFFIX_HYPERION_PRIORITY_LIGHT}".strip() - - @property - def entity_registry_enabled_default(self) -> bool: - """Whether or not the entity is enabled by default.""" - return False - - @property - def is_on(self) -> bool: - """Return true if light is on.""" - priority = self._get_priority_entry_that_dictates_state() - return ( - priority is not None - and not HyperionPriorityLight._is_priority_entry_black(priority) - ) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the light.""" - if not await self._client.async_send_clear( - **{const.KEY_PRIORITY: self._get_option(CONF_PRIORITY)} - ): - return - await self._client.async_send_set_color( - **{ - const.KEY_PRIORITY: self._get_option(CONF_PRIORITY), - const.KEY_COLOR: COLOR_BLACK, - const.KEY_ORIGIN: DEFAULT_ORIGIN, - } - ) - - @property - def _support_external_effects(self) -> bool: - """Whether or not to support setting external effects from the light entity.""" - return False - - def _get_priority_entry_that_dictates_state(self) -> dict[str, Any] | None: - """Get the relevant Hyperion priority entry to consider.""" - # Return the active priority (if any) at the configured HA priority. - for candidate in self._client.priorities or []: - if const.KEY_PRIORITY not in candidate: - continue - if candidate[const.KEY_PRIORITY] == self._get_option( - CONF_PRIORITY - ) and candidate.get(const.KEY_ACTIVE, False): - # Explicit type specifier to ensure this works when the underlying - # (typed) library is installed along with the tests. Casts would trigger - # a redundant-cast warning in this case. - output: dict[str, Any] = candidate - return output + # Return whether or not the HA priority is among the active priorities. + for priority in self._client.priorities or []: + if priority.get(const.KEY_PRIORITY) == self._get_option(CONF_PRIORITY): + return priority return None - - @classmethod - def _is_priority_entry_black(cls, priority: dict[str, Any] | None) -> bool: - """Determine if a given priority entry is the color black.""" - if ( - priority - and priority.get(const.KEY_COMPONENTID) == const.KEY_COMPONENTID_COLOR - ): - rgb_color = priority.get(const.KEY_VALUE, {}).get(const.KEY_RGB) - if rgb_color is not None and tuple(rgb_color) == COLOR_BLACK: - return True - return False - - def _allow_priority_update(self, priority: dict[str, Any] | None = None) -> bool: - """Determine whether to allow a Hyperion priority to update entity attributes.""" - # Black is treated as 'off' (and Home Assistant does not support selecting black - # from the color selector). Do not set our internal attributes if the priority is - # 'off' (i.e. if black is active). Do this to ensure it seamlessly turns back on - # at the correct prior color on the next 'on' call. - return not HyperionPriorityLight._is_priority_entry_black(priority) - - -LIGHT_TYPES = { - TYPE_HYPERION_LIGHT: HyperionLight, - TYPE_HYPERION_PRIORITY_LIGHT: HyperionPriorityLight, -} diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index e0b381d2362..5735e1ab421 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine from datetime import datetime from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate, ParamSpec, TypeVar, cast import httpx from iaqualink.client import AqualinkClient @@ -243,6 +243,8 @@ class AqualinkEntity(Entity): identifiers={(DOMAIN, self.unique_id)}, manufacturer=self.dev.manufacturer, model=self.dev.model, - name=self.name, + # Instead of setting the device name to the entity name, iaqualink + # should be updated to set has_entity_name = True + name=cast(str | None, self.name), via_device=(DOMAIN, self.dev.system.serial), ) diff --git a/homeassistant/components/ibeacon/device_tracker.py b/homeassistant/components/ibeacon/device_tracker.py index 4c9337e54ce..8e194ac27b1 100644 --- a/homeassistant/components/ibeacon/device_tracker.py +++ b/homeassistant/components/ibeacon/device_tracker.py @@ -48,6 +48,8 @@ async def async_setup_entry( class IBeaconTrackerEntity(IBeaconEntity, BaseTrackerEntity): """An iBeacon Tracker entity.""" + _attr_name = None + def __init__( self, coordinator: IBeaconCoordinator, diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json index a805277cb71..6f00f63b090 100644 --- a/homeassistant/components/ibeacon/manifest.json +++ b/homeassistant/components/ibeacon/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ibeacon", "iot_class": "local_push", "loggers": ["bleak"], - "requirements": ["ibeacon_ble==1.0.1"] + "requirements": ["ibeacon-ble==1.0.1"] } diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py index c0b9e92decc..b3895ce23b4 100644 --- a/homeassistant/components/ibeacon/sensor.py +++ b/homeassistant/components/ibeacon/sensor.py @@ -38,7 +38,6 @@ class IBeaconSensorEntityDescription(SensorEntityDescription, IBeaconRequiredKey SENSOR_DESCRIPTIONS = ( IBeaconSensorEntityDescription( key="rssi", - name="Signal Strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, @@ -47,7 +46,7 @@ SENSOR_DESCRIPTIONS = ( ), IBeaconSensorEntityDescription( key="power", - name="Power", + translation_key="power", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, @@ -56,7 +55,7 @@ SENSOR_DESCRIPTIONS = ( ), IBeaconSensorEntityDescription( key="estimated_distance", - name="Estimated Distance", + translation_key="estimated_distance", icon="mdi:signal-distance-variant", native_unit_of_measurement=UnitOfLength.METERS, value_fn=lambda ibeacon_advertisement: ibeacon_advertisement.distance, @@ -65,7 +64,7 @@ SENSOR_DESCRIPTIONS = ( ), IBeaconSensorEntityDescription( key="vendor", - name="Vendor", + translation_key="vendor", entity_registry_enabled_default=False, value_fn=lambda ibeacon_advertisement: ibeacon_advertisement.vendor, ), diff --git a/homeassistant/components/ibeacon/strings.json b/homeassistant/components/ibeacon/strings.json index b91ba459bd7..be3f7020cbe 100644 --- a/homeassistant/components/ibeacon/strings.json +++ b/homeassistant/components/ibeacon/strings.json @@ -19,5 +19,18 @@ } } } + }, + "entity": { + "sensor": { + "power": { + "name": "Power" + }, + "estimated_distance": { + "name": "Estimated distance" + }, + "vendor": { + "name": "Vendor" + } + } } } diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py index 6cdde2249c8..4be5487f755 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -263,13 +263,12 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.api.validate_2fa_code, self._verification_code ): raise PyiCloudException("The code you entered is not valid.") - else: - if not await self.hass.async_add_executor_job( - self.api.validate_verification_code, - self._trusted_device, - self._verification_code, - ): - raise PyiCloudException("The code you entered is not valid.") + elif not await self.hass.async_add_executor_job( + self.api.validate_verification_code, + self._trusted_device, + self._verification_code, + ): + raise PyiCloudException("The code you entered is not valid.") except PyiCloudException as error: # Reset to the initial 2FA state to allow the user to retry _LOGGER.error("Failed to verify verification code: %s", error) diff --git a/homeassistant/components/ign_sismologia/manifest.json b/homeassistant/components/ign_sismologia/manifest.json index 0fc69a7ba19..6eeea6b4a02 100644 --- a/homeassistant/components/ign_sismologia/manifest.json +++ b/homeassistant/components/ign_sismologia/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["georss_ign_sismologia_client"], - "requirements": ["georss_ign_sismologia_client==0.6"] + "requirements": ["georss-ign-sismologia-client==0.6"] } diff --git a/homeassistant/components/ihc/light.py b/homeassistant/components/ihc/light.py index 089317ca7d7..b469cb54aee 100644 --- a/homeassistant/components/ihc/light.py +++ b/homeassistant/components/ihc/light.py @@ -96,29 +96,26 @@ class IhcLight(IHCDevice, LightEntity): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] - else: - if (brightness := self._brightness) == 0: - brightness = 255 + elif (brightness := self._brightness) == 0: + brightness = 255 if self._dimmable: await async_set_int( self.hass, self.ihc_controller, self.ihc_id, int(brightness * 100 / 255) ) + elif self._ihc_on_id: + await async_pulse(self.hass, self.ihc_controller, self._ihc_on_id) else: - if self._ihc_on_id: - await async_pulse(self.hass, self.ihc_controller, self._ihc_on_id) - else: - await async_set_bool(self.hass, self.ihc_controller, self.ihc_id, True) + await async_set_bool(self.hass, self.ihc_controller, self.ihc_id, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" if self._dimmable: await async_set_int(self.hass, self.ihc_controller, self.ihc_id, 0) + elif self._ihc_off_id: + await async_pulse(self.hass, self.ihc_controller, self._ihc_off_id) else: - if self._ihc_off_id: - await async_pulse(self.hass, self.ihc_controller, self._ihc_off_id) - else: - await async_set_bool(self.hass, self.ihc_controller, self.ihc_id, False) + await async_set_bool(self.hass, self.ihc_controller, self.ihc_id, False) def on_ihc_change(self, ihc_id, value): """Handle IHC notifications.""" diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py new file mode 100644 index 00000000000..8daea2cdd46 --- /dev/null +++ b/homeassistant/components/image/__init__.py @@ -0,0 +1,275 @@ +"""The image integration.""" +from __future__ import annotations + +import asyncio +import collections +from contextlib import suppress +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from random import SystemRandom +from typing import Final, final + +from aiohttp import hdrs, web +import async_timeout +import httpx + +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType + +from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL: Final = timedelta(seconds=30) +ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" + +DEFAULT_CONTENT_TYPE: Final = "image/jpeg" +ENTITY_IMAGE_URL: Final = "/api/image_proxy/{0}?token={1}" + +TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=5) +_RND: Final = SystemRandom() + +GET_IMAGE_TIMEOUT: Final = 10 + + +@dataclass +class ImageEntityDescription(EntityDescription): + """A class that describes image entities.""" + + +@dataclass +class Image: + """Represent an image.""" + + content_type: str + content: bytes + + +class ImageContentTypeError(HomeAssistantError): + """Error with the content type while loading an image.""" + + +def valid_image_content_type(content_type: str | None) -> str: + """Validate the assigned content type is one of an image.""" + if content_type is None or content_type.split("/", 1)[0] != "image": + raise ImageContentTypeError + return content_type + + +async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: + """Fetch image from an image entity.""" + with suppress(asyncio.CancelledError, asyncio.TimeoutError, ImageContentTypeError): + async with async_timeout.timeout(timeout): + if image_bytes := await image_entity.async_image(): + content_type = valid_image_content_type(image_entity.content_type) + image = Image(content_type, image_bytes) + return image + + raise HomeAssistantError("Unable to get image") + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the image component.""" + component = hass.data[DOMAIN] = EntityComponent[ImageEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + hass.http.register_view(ImageView(component)) + + await component.async_setup(config) + + @callback + def update_tokens(time: datetime) -> None: + """Update tokens of the entities.""" + for entity in component.entities: + entity.async_update_token() + entity.async_write_ha_state() + + unsub = async_track_time_interval( + hass, update_tokens, TOKEN_CHANGE_INTERVAL, name="Image update tokens" + ) + + @callback + def unsub_track_time_interval(_event: Event) -> None: + """Unsubscribe track time interval timer.""" + unsub() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[ImageEntity] = 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[ImageEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +class ImageEntity(Entity): + """The base class for image entities.""" + + # Entity Properties + _attr_content_type: str = DEFAULT_CONTENT_TYPE + _attr_image_last_updated: datetime | None = None + _attr_image_url: str | None | UndefinedType = UNDEFINED + _attr_should_poll: bool = False # No need to poll image entities + _attr_state: None = None # State is determined by last_updated + _cached_image: Image | None = None + + def __init__(self, hass: HomeAssistant, verify_ssl: bool = False) -> None: + """Initialize an image entity.""" + self._client = get_async_client(hass, verify_ssl=verify_ssl) + self.access_tokens: collections.deque = collections.deque([], 2) + self.async_update_token() + + @property + def content_type(self) -> str: + """Image content type.""" + return self._attr_content_type + + @property + def entity_picture(self) -> str | None: + """Return a link to the image as entity picture.""" + if self._attr_entity_picture is not None: + return self._attr_entity_picture + return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) + + @property + def image_last_updated(self) -> datetime | None: + """The time when the image was last updated.""" + return self._attr_image_last_updated + + @property + def image_url(self) -> str | None | UndefinedType: + """Return URL of image.""" + return self._attr_image_url + + def image(self) -> bytes | None: + """Return bytes of image.""" + raise NotImplementedError() + + async def _async_load_image_from_url(self, url: str) -> Image | None: + """Load an image by url.""" + try: + response = await self._client.get( + url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True + ) + response.raise_for_status() + content_type = response.headers.get("content-type") + return Image( + content=response.content, + content_type=valid_image_content_type(content_type), + ) + except httpx.TimeoutException: + _LOGGER.error("%s: Timeout getting image from %s", self.entity_id, url) + return None + except (httpx.RequestError, httpx.HTTPStatusError) as err: + _LOGGER.error( + "%s: Error getting new image from %s: %s", + self.entity_id, + url, + err, + ) + return None + except ImageContentTypeError: + _LOGGER.error( + "%s: Image from %s has invalid content type: %s", + self.entity_id, + url, + content_type, + ) + return None + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + + if self._cached_image: + return self._cached_image.content + if (url := self.image_url) is not UNDEFINED: + if not url or (image := await self._async_load_image_from_url(url)) is None: + return None + self._cached_image = image + self._attr_content_type = image.content_type + return image.content + return await self.hass.async_add_executor_job(self.image) + + @property + @final + def state(self) -> str | None: + """Return the state.""" + if self.image_last_updated is None: + return None + return self.image_last_updated.isoformat() + + @final + @property + def state_attributes(self) -> dict[str, str | None]: + """Return the state attributes.""" + return {"access_token": self.access_tokens[-1]} + + @callback + def async_update_token(self) -> None: + """Update the used token.""" + self.access_tokens.append(hex(_RND.getrandbits(256))[2:]) + + +class ImageView(HomeAssistantView): + """View to serve an image.""" + + name = "api:image:image" + requires_auth = False + url = "/api/image_proxy/{entity_id}" + + def __init__(self, component: EntityComponent[ImageEntity]) -> None: + """Initialize an image view.""" + self.component = component + + async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: + """Start a GET request.""" + if (image_entity := self.component.get_entity(entity_id)) is None: + raise web.HTTPNotFound() + + authenticated = ( + request[KEY_AUTHENTICATED] + or request.query.get("token") in image_entity.access_tokens + ) + + if not authenticated: + # Attempt with invalid bearer token, raise unauthorized + # so ban middleware can handle it. + if hdrs.AUTHORIZATION in request.headers: + raise web.HTTPUnauthorized() + # Invalid sigAuth or image entity access token + raise web.HTTPForbidden() + + return await self.handle(request, image_entity) + + async def handle( + self, request: web.Request, image_entity: ImageEntity + ) -> web.StreamResponse: + """Serve image.""" + try: + image = await _async_get_image(image_entity, IMAGE_TIMEOUT) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError() from ex + + return web.Response(body=image.content, content_type=image.content_type) diff --git a/homeassistant/components/image/const.py b/homeassistant/components/image/const.py new file mode 100644 index 00000000000..d262bb460f7 --- /dev/null +++ b/homeassistant/components/image/const.py @@ -0,0 +1,6 @@ +"""Constants for the image integration.""" +from typing import Final + +DOMAIN: Final = "image" + +IMAGE_TIMEOUT: Final = 10 diff --git a/homeassistant/components/image/manifest.json b/homeassistant/components/image/manifest.json new file mode 100644 index 00000000000..0335710a30b --- /dev/null +++ b/homeassistant/components/image/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "image", + "name": "Image", + "codeowners": ["@home-assistant/core"], + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/image", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/image/recorder.py b/homeassistant/components/image/recorder.py new file mode 100644 index 00000000000..5c141220881 --- /dev/null +++ b/homeassistant/components/image/recorder.py @@ -0,0 +1,10 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude access_token and entity_picture from being recorded in the database.""" + return {"access_token", "entity_picture"} diff --git a/homeassistant/components/image/strings.json b/homeassistant/components/image/strings.json new file mode 100644 index 00000000000..ea7ecd16956 --- /dev/null +++ b/homeassistant/components/image/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Image", + "entity_component": { + "_": { + "name": "[%key:component::image::title%]" + } + } +} diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 766be89f0d4..569df9c65e4 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -8,9 +8,9 @@ import secrets import shutil from typing import Any -from PIL import Image, ImageOps, UnidentifiedImageError from aiohttp import hdrs, web from aiohttp.web_request import FileField +from PIL import Image, ImageOps, UnidentifiedImageError import voluptuous as vol from homeassistant.components.http.static import CACHE_HEADERS diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 947c3cb67d5..48c57fb5d03 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["pillow==9.5.0"] + "requirements": ["Pillow==9.5.0"] } diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 6a737df7476..00be545fb67 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -19,18 +19,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult -from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( BooleanSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, - TextSelector, - TextSelectorConfig, - TextSelectorType, + TemplateSelector, + TemplateSelectorConfig, ) -from homeassistant.helpers.template import Template from homeassistant.util.ssl import SSLCipherList from .const import ( @@ -57,9 +54,7 @@ CIPHER_SELECTOR = SelectSelector( translation_key=CONF_SSL_CIPHER_LIST, ) ) -TEMPLATE_SELECTOR = TextSelector( - TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True) -) +TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) CONFIG_SCHEMA = vol.Schema( { @@ -125,11 +120,6 @@ async def validate_input( errors[CONF_CHARSET] = "invalid_charset" else: errors[CONF_SEARCH] = "invalid_search" - if template := user_input.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE): - try: - Template(template, hass=hass).ensure_valid() - except TemplateError: - errors[CONF_CUSTOM_EVENT_DATA_TEMPLATE] = "invalid_template" return errors diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index 39dfc6c0d48..3c35d00f714 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -1,7 +1,7 @@ { "domain": "imap", "name": "IMAP", - "codeowners": ["@engrbm87", "@jbouwh"], + "codeowners": ["@jbouwh"], "config_flow": true, "dependencies": ["repairs"], "documentation": "https://www.home-assistant.io/integrations/imap", diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 776abc174a2..cd6da667ccb 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -1,7 +1,11 @@ """IMAP sensor support.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant @@ -13,6 +17,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator from .const import DOMAIN +IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( + key="imap_mail_count", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -22,8 +32,7 @@ async def async_setup_entry( coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( hass.data[DOMAIN][entry.entry_id] ) - - async_add_entities([ImapSensor(coordinator)]) + async_add_entities([ImapSensor(coordinator, IMAP_MAIL_COUNT_DESCRIPTION)]) class ImapSensor( @@ -34,13 +43,16 @@ class ImapSensor( _attr_icon = "mdi:email-outline" _attr_has_entity_name = True + _attr_name = None def __init__( self, coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) + self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.entry_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 1e237f72b44..6fad8895931 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -52,8 +52,7 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", - "invalid_search": "[%key:component::imap::config::error::invalid_search%]", - "invalid_template": "Invalid template" + "invalid_search": "[%key:component::imap::config::error::invalid_search%]" } }, "selector": { diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py index 1a148f4591b..f2041b947df 100644 --- a/homeassistant/components/imap_email_content/__init__.py +++ b/homeassistant/components/imap_email_content/__init__.py @@ -2,10 +2,15 @@ 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.""" diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index b1b391aaaab..9e8cabbe253 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -28,6 +28,9 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" extra_key: str | None = None + # IncomfortSensor does not support UNDEFINED or None, + # restrict the type to str + name: str = "" SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index 8fde1c2d8be..f879ab37e8f 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -145,12 +145,11 @@ def validate_version_specific_config(conf: dict) -> dict: f" {CONF_API_VERSION} is {DEFAULT_API_VERSION}" ) - else: - if CONF_TOKEN in conf: - raise vol.Invalid( - f"{CONF_TOKEN} and {CONF_BUCKET} are only allowed when" - f" {CONF_API_VERSION} is {API_VERSION_2}" - ) + elif CONF_TOKEN in conf: + raise vol.Invalid( + f"{CONF_TOKEN} and {CONF_BUCKET} are only allowed when" + f" {CONF_API_VERSION} is {API_VERSION_2}" + ) return conf diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index 67aaae225a8..b4f643e876f 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -339,7 +339,7 @@ class InfluxQLSensorData: return self.query = ( - f"select {self.group}({self.field}) as {INFLUX_CONF_VALUE} from" + f"select {self.group}({self.field}) as {INFLUX_CONF_VALUE} from" # noqa: S608 f" {self.measurement} where {where_clause}" ) diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 769b2d794d0..8762769194f 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -292,13 +292,12 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): else: current_datetime = py_datetime.datetime.combine(date, DEFAULT_TIME) + elif (time := dt_util.parse_time(old_state.state)) is None: + current_datetime = dt_util.parse_datetime(default_value) else: - if (time := dt_util.parse_time(old_state.state)) is None: - current_datetime = dt_util.parse_datetime(default_value) - else: - current_datetime = py_datetime.datetime.combine( - py_datetime.date.today(), time - ) + current_datetime = py_datetime.datetime.combine( + py_datetime.date.today(), time + ) self._current_datetime = current_datetime.replace( tzinfo=dt_util.DEFAULT_TIME_ZONE diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 1667f5fb779..a074ad4600b 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -9,13 +9,12 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from . import api from .const import ( CONF_CAT, - CONF_DEV_PATH, CONF_DIM_STEPS, CONF_HOUSECODE, CONF_OVERRIDE, @@ -25,7 +24,6 @@ from .const import ( DOMAIN, INSTEON_PLATFORMS, ) -from .schemas import convert_yaml_to_config_flow from .utils import ( add_insteon_events, async_register_services, @@ -36,6 +34,8 @@ from .utils import ( _LOGGER = logging.getLogger(__name__) OPTIONS = "options" +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) + async def async_get_device_config(hass, config_entry): """Initiate the connection and services.""" @@ -77,26 +77,6 @@ async def close_insteon_connection(*args): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Insteon platform.""" - hass.data[DOMAIN] = {} - if DOMAIN not in config: - return True - - conf = dict(config[DOMAIN]) - hass.data[DOMAIN][CONF_DEV_PATH] = conf.pop(CONF_DEV_PATH, None) - - if not conf: - return True - - data, options = convert_yaml_to_config_flow(conf) - - if options: - hass.data[DOMAIN][OPTIONS] = options - # Create a config entry with the connection data - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=data - ) - ) return True diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 15ce7c849e6..f5bafd935a0 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -163,25 +163,14 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id=step_id, data_schema=data_schema, errors=errors ) - async def async_step_import(self, import_info): - """Import a yaml entry as a config entry.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - if not await _async_connect(**import_info): - return self.async_abort(reason="cannot_connect") - return self.async_create_entry(title="", data=import_info) - async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: """Handle USB discovery.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - dev_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, discovery_info.device - ) - self._device_path = dev_path + self._device_path = discovery_info.device self._device_name = usb.human_readable_device_name( - dev_path, + discovery_info.device, discovery_info.serial_number, discovery_info.manufacturer, discovery_info.description, diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 84b586e7649..e6b22a8cbb9 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -22,26 +22,13 @@ import homeassistant.helpers.config_validation as cv from .const import ( CONF_CAT, - CONF_DEV_PATH, CONF_DIM_STEPS, - CONF_FIRMWARE, CONF_HOUSECODE, - CONF_HUB_PASSWORD, - CONF_HUB_USERNAME, - CONF_HUB_VERSION, - CONF_IP_PORT, CONF_OVERRIDE, - CONF_PLM_HUB_MSG, - CONF_PRODUCT_KEY, CONF_SUBCAT, CONF_UNITCODE, CONF_X10, - CONF_X10_ALL_LIGHTS_OFF, - CONF_X10_ALL_LIGHTS_ON, - CONF_X10_ALL_UNITS_OFF, - DOMAIN, HOUSECODES, - INSTEON_ADDR_REGEX, PORT_HUB_V1, PORT_HUB_V2, SRV_ALL_LINK_GROUP, @@ -53,88 +40,6 @@ from .const import ( X10_PLATFORMS, ) - -def set_default_port(schema: dict) -> dict: - """Set the default port based on the Hub version.""" - # If the ip_port is found do nothing - # If it is not found the set the default - if not schema.get(CONF_IP_PORT): - hub_version = schema.get(CONF_HUB_VERSION) - # Found hub_version but not ip_port - schema[CONF_IP_PORT] = PORT_HUB_V1 if hub_version == 1 else PORT_HUB_V2 - return schema - - -def insteon_address(value: str) -> str: - """Validate an Insteon address.""" - if not INSTEON_ADDR_REGEX.match(value): - raise vol.Invalid("Invalid Insteon Address") - return str(value).replace(".", "").lower() - - -CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_ADDRESS): cv.string, - vol.Optional(CONF_CAT): cv.byte, - vol.Optional(CONF_SUBCAT): cv.byte, - vol.Optional(CONF_FIRMWARE): cv.byte, - vol.Optional(CONF_PRODUCT_KEY): cv.byte, - vol.Optional(CONF_PLATFORM): cv.string, - } - ), -) - - -CONF_X10_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_HOUSECODE): cv.string, - vol.Required(CONF_UNITCODE): vol.Range(min=1, max=16), - vol.Required(CONF_PLATFORM): cv.string, - vol.Optional(CONF_DIM_STEPS): vol.Range(min=2, max=255), - } - ) -) - - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.deprecated(CONF_X10_ALL_UNITS_OFF), - cv.deprecated(CONF_X10_ALL_LIGHTS_ON), - cv.deprecated(CONF_X10_ALL_LIGHTS_OFF), - vol.Schema( - { - vol.Exclusive( - CONF_PORT, "plm_or_hub", msg=CONF_PLM_HUB_MSG - ): cv.string, - vol.Exclusive( - CONF_HOST, "plm_or_hub", msg=CONF_PLM_HUB_MSG - ): cv.string, - vol.Optional(CONF_IP_PORT): cv.port, - vol.Optional(CONF_HUB_USERNAME): cv.string, - vol.Optional(CONF_HUB_PASSWORD): cv.string, - vol.Optional(CONF_HUB_VERSION, default=2): vol.In([1, 2]), - vol.Optional(CONF_OVERRIDE): vol.All( - cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA] - ), - vol.Optional(CONF_X10): vol.All( - cv.ensure_list_csv, [CONF_X10_SCHEMA] - ), - vol.Optional(CONF_DEV_PATH): cv.string, - }, - extra=vol.ALLOW_EXTRA, - required=True, - ), - cv.has_at_least_one_key(CONF_PORT, CONF_HOST), - set_default_port, - ) - }, - extra=vol.ALLOW_EXTRA, -) - - ADD_ALL_LINK_SCHEMA = vol.Schema( { vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255), @@ -170,18 +75,6 @@ TRIGGER_SCENE_SCHEMA = vol.Schema( ADD_DEFAULT_LINKS_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) -SCENE_ENTITY_SCHEMA = vol.Schema( - [ - { - vol.Required(CONF_ADDRESS): str, - vol.Required("data1"): int, - vol.Required("data2"): int, - vol.Required("data3"): int, - } - ] -) - - def normalize_byte_entry_to_int(entry: int | bytes | str): """Format a hex entry value.""" if isinstance(entry, int): @@ -338,32 +231,3 @@ def build_remove_x10_schema(data): unitcode = device[CONF_UNITCODE] selection.append(f"Housecode: {housecode}, Unitcode: {unitcode}") return vol.Schema({vol.Required(CONF_DEVICE): vol.In(selection)}) - - -def convert_yaml_to_config_flow(yaml_config): - """Convert the YAML based configuration to a config flow configuration.""" - config = {} - if yaml_config.get(CONF_HOST): - hub_version = yaml_config.get(CONF_HUB_VERSION, 2) - default_port = PORT_HUB_V2 if hub_version == 2 else PORT_HUB_V1 - config[CONF_HOST] = yaml_config.get(CONF_HOST) - config[CONF_PORT] = yaml_config.get(CONF_PORT, default_port) - config[CONF_HUB_VERSION] = hub_version - if hub_version == 2: - config[CONF_USERNAME] = yaml_config[CONF_USERNAME] - config[CONF_PASSWORD] = yaml_config[CONF_PASSWORD] - else: - config[CONF_DEVICE] = yaml_config[CONF_PORT] - - options = {} - for old_override in yaml_config.get(CONF_OVERRIDE, []): - override = {} - override[CONF_ADDRESS] = str(Address(old_override[CONF_ADDRESS])) - override[CONF_CAT] = normalize_byte_entry_to_int(old_override[CONF_CAT]) - override[CONF_SUBCAT] = normalize_byte_entry_to_int(old_override[CONF_SUBCAT]) - options = add_device_override(options, override) - - for x10_device in yaml_config.get(CONF_X10, []): - options = add_x10_device(options, x10_device) - - return config, options diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index b28b426d3af..af4248e5e3b 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -28,7 +28,12 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -140,6 +145,28 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) + source_entity = er.EntityRegistry.async_get(registry, source_entity_id) + dev_reg = dr.async_get(hass) + # Resolve source entity device + if ( + (source_entity is not None) + and (source_entity.device_id is not None) + and ( + ( + device := dev_reg.async_get( + device_id=source_entity.device_id, + ) + ) + is not None + ) + ): + device_info = DeviceInfo( + identifiers=device.identifiers, + connections=device.connections, + ) + else: + device_info = None + unit_prefix = config_entry.options[CONF_UNIT_PREFIX] if unit_prefix == "none": unit_prefix = None @@ -152,6 +179,7 @@ async def async_setup_entry( unique_id=config_entry.entry_id, unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], + device_info=device_info, ) async_add_entities([integral]) @@ -194,6 +222,7 @@ class IntegrationSensor(RestoreSensor): unique_id: str | None, unit_prefix: str | None, unit_time: UnitOfTime, + device_info: DeviceInfo | None = None, ) -> None: """Initialize the integration sensor.""" self._attr_unique_id = unique_id @@ -211,6 +240,7 @@ class IntegrationSensor(RestoreSensor): self._attr_icon = "mdi:chart-histogram" self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None + self._attr_device_info = device_info def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index 649234d7568..5d305db8feb 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -66,7 +66,7 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity): self.last_temp = coordinator.data.thermostat_setpoint_c @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return current hvac mode.""" if self.coordinator.read_api.data.thermostat_on: return HVACMode.HEAT diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 1f390d35370..b2c77fed3af 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -70,18 +70,22 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): if state.domain == COVER_DOMAIN: # on = open # off = close - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER - if self.service == SERVICE_TURN_ON - else SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: state.entity_id}, - context=intent_obj.context, - blocking=True, - limit=self.service_timeout, + await self._run_then_background( + hass.async_create_task( + hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER + if self.service == SERVICE_TURN_ON + else SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: state.entity_id}, + context=intent_obj.context, + blocking=True, + ) + ) ) + return - elif not hass.services.has_service(state.domain, self.service): + if not hass.services.has_service(state.domain, self.service): raise intent.IntentHandleError( f"Service {self.service} does not support entity {state.entity_id}" ) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 2ec898bfb0e..55c4947fe4a 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -5,9 +5,16 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, intent, script, template +from homeassistant.const import CONF_TYPE, SERVICE_RELOAD +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import ( + config_validation as cv, + intent, + script, + service, + template, +) +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -55,10 +62,27 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the intent script component.""" - intents = config[DOMAIN] +async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None: + """Handle start Intent Script service call.""" + new_config = await async_integration_yaml_config(hass, DOMAIN) + existing_intents = hass.data[DOMAIN] + + for intent_type in existing_intents: + intent.async_remove(hass, intent_type) + + if not new_config or DOMAIN not in new_config: + hass.data[DOMAIN] = {} + return + + new_intents = new_config[DOMAIN] + + async_load_intents(hass, new_intents) + + +def async_load_intents(hass: HomeAssistant, intents: dict): + """Load YAML intents into the intent system.""" template.attach(hass, intents) + hass.data[DOMAIN] = intents for intent_type, conf in intents.items(): if CONF_ACTION in conf: @@ -67,6 +91,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the intent script component.""" + intents = config[DOMAIN] + + async_load_intents(hass, intents) + + async def _handle_reload(servie_call: ServiceCall) -> None: + return await async_reload(hass, servie_call) + + service.async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD, + _handle_reload, + ) + return True diff --git a/homeassistant/components/intent_script/services.yaml b/homeassistant/components/intent_script/services.yaml new file mode 100644 index 00000000000..bb981dbc69c --- /dev/null +++ b/homeassistant/components/intent_script/services.yaml @@ -0,0 +1,3 @@ +reload: + name: Reload + description: Reload the intent_script configuration. diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index 866e79cbe40..dd46593998e 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -18,7 +18,7 @@ from .const import DATA_API, DATA_LOCATION, DOMAIN DEFAULT_NAME = "ipma" -PLATFORMS = [Platform.WEATHER] +PLATFORMS = [Platform.WEATHER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index 81ab8f98014..eb361d3f9d5 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -5,8 +5,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME import homeassistant.helpers.config_validation as cv -from .const import DOMAIN, HOME_LOCATION_NAME -from .weather import FORECAST_MODE +from .const import DOMAIN, FORECAST_MODE, HOME_LOCATION_NAME class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py index 515fb501fbd..2d715011e43 100644 --- a/homeassistant/components/ipma/const.py +++ b/homeassistant/components/ipma/const.py @@ -1,5 +1,24 @@ """Constants for IPMA component.""" -from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from datetime import timedelta + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_EXCEPTIONAL, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_CONDITION_WINDY_VARIANT, + DOMAIN as WEATHER_DOMAIN, +) DOMAIN = "ipma" @@ -9,3 +28,27 @@ DATA_API = "api" DATA_LOCATION = "location" ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) + +CONDITION_CLASSES = { + ATTR_CONDITION_CLOUDY: [4, 5, 24, 25, 27], + ATTR_CONDITION_FOG: [16, 17, 26], + ATTR_CONDITION_HAIL: [21, 22], + ATTR_CONDITION_LIGHTNING: [19], + ATTR_CONDITION_LIGHTNING_RAINY: [20, 23], + ATTR_CONDITION_PARTLYCLOUDY: [2, 3], + ATTR_CONDITION_POURING: [8, 11], + ATTR_CONDITION_RAINY: [6, 7, 9, 10, 12, 13, 14, 15], + ATTR_CONDITION_SNOWY: [18], + ATTR_CONDITION_SNOWY_RAINY: [], + ATTR_CONDITION_SUNNY: [1], + ATTR_CONDITION_WINDY: [], + ATTR_CONDITION_WINDY_VARIANT: [], + ATTR_CONDITION_EXCEPTIONAL: [], + ATTR_CONDITION_CLEAR_NIGHT: [-1], +} + +FORECAST_MODE = ["hourly", "daily"] + +ATTRIBUTION = "Instituto Português do Mar e Atmosfera" diff --git a/homeassistant/components/ipma/entity.py b/homeassistant/components/ipma/entity.py new file mode 100644 index 00000000000..bc8136b6206 --- /dev/null +++ b/homeassistant/components/ipma/entity.py @@ -0,0 +1,26 @@ +"""Base Entity for IPMA.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class IPMADevice(Entity): + """Common IPMA Device Information.""" + + def __init__(self, location) -> None: + """Initialize device information.""" + self._location = location + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + f"{self._location.station_latitude}, {self._location.station_longitude}", + ) + }, + manufacturer=DOMAIN, + name=self._location.name, + ) diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py new file mode 100644 index 00000000000..f02f8b7d9d0 --- /dev/null +++ b/homeassistant/components/ipma/sensor.py @@ -0,0 +1,89 @@ +"""Support for IPMA sensors.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging + +import async_timeout +from pyipma.api import IPMA_API +from pyipma.location import Location + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import Throttle + +from .const import DATA_API, DATA_LOCATION, DOMAIN, MIN_TIME_BETWEEN_UPDATES +from .entity import IPMADevice + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class IPMARequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]] + + +@dataclass +class IPMASensorEntityDescription(SensorEntityDescription, IPMARequiredKeysMixin): + """Describes IPMA sensor entity.""" + + +async def async_retrive_rcm(location: Location, api: IPMA_API) -> int | None: + """Retrieve RCM.""" + fire_risk = await location.fire_risk(api) + if fire_risk: + return fire_risk.rcm + return None + + +SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( + IPMASensorEntityDescription( + key="rcm", + translation_key="fire_risk", + value_fn=async_retrive_rcm, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the IPMA sensor platform.""" + api = hass.data[DOMAIN][entry.entry_id][DATA_API] + location = hass.data[DOMAIN][entry.entry_id][DATA_LOCATION] + + entities = [IPMASensor(api, location, description) for description in SENSOR_TYPES] + + async_add_entities(entities, True) + + +class IPMASensor(SensorEntity, IPMADevice): + """Representation of an IPMA sensor.""" + + entity_description: IPMASensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + api: IPMA_API, + location: Location, + description: IPMASensorEntityDescription, + ) -> None: + """Initialize the IPMA Sensor.""" + IPMADevice.__init__(self, location) + self.entity_description = description + self._api = api + self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self.entity_description.key}" + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self) -> None: + """Update Fire risk.""" + async with async_timeout.timeout(10): + self._attr_native_value = await self.entity_description.value_fn( + self._location, self._api + ) diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index 0dd013135dc..b9f50c66f9e 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -18,5 +18,12 @@ "info": { "api_endpoint_reachable": "IPMA API endpoint reachable" } + }, + "entity": { + "sensor": { + "fire_risk": { + "name": "Fire risk" + } + } } } diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index bfd1b820c7a..811eddf91bf 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -1,7 +1,6 @@ """Support for IPMA weather service.""" from __future__ import annotations -from datetime import timedelta import logging import async_timeout @@ -10,21 +9,6 @@ from pyipma.forecast import Forecast from pyipma.location import Location from homeassistant.components.weather import ( - ATTR_CONDITION_CLEAR_NIGHT, - ATTR_CONDITION_CLOUDY, - ATTR_CONDITION_EXCEPTIONAL, - ATTR_CONDITION_FOG, - ATTR_CONDITION_HAIL, - ATTR_CONDITION_LIGHTNING, - ATTR_CONDITION_LIGHTNING_RAINY, - ATTR_CONDITION_PARTLYCLOUDY, - ATTR_CONDITION_POURING, - ATTR_CONDITION_RAINY, - ATTR_CONDITION_SNOWY, - ATTR_CONDITION_SNOWY_RAINY, - ATTR_CONDITION_SUNNY, - ATTR_CONDITION_WINDY, - ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, @@ -48,34 +32,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle -from .const import DATA_API, DATA_LOCATION, DOMAIN +from .const import ( + ATTRIBUTION, + CONDITION_CLASSES, + DATA_API, + DATA_LOCATION, + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, +) +from .entity import IPMADevice _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Instituto Português do Mar e Atmosfera" - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) - -CONDITION_CLASSES = { - ATTR_CONDITION_CLOUDY: [4, 5, 24, 25, 27], - ATTR_CONDITION_FOG: [16, 17, 26], - ATTR_CONDITION_HAIL: [21, 22], - ATTR_CONDITION_LIGHTNING: [19], - ATTR_CONDITION_LIGHTNING_RAINY: [20, 23], - ATTR_CONDITION_PARTLYCLOUDY: [2, 3], - ATTR_CONDITION_POURING: [8, 11], - ATTR_CONDITION_RAINY: [6, 7, 9, 10, 12, 13, 14, 15], - ATTR_CONDITION_SNOWY: [18], - ATTR_CONDITION_SNOWY_RAINY: [], - ATTR_CONDITION_SUNNY: [1], - ATTR_CONDITION_WINDY: [], - ATTR_CONDITION_WINDY_VARIANT: [], - ATTR_CONDITION_EXCEPTIONAL: [], - ATTR_CONDITION_CLEAR_NIGHT: [-1], -} - -FORECAST_MODE = ["hourly", "daily"] - async def async_setup_entry( hass: HomeAssistant, @@ -110,7 +78,7 @@ async def async_setup_entry( async_add_entities([IPMAWeather(location, api, config_entry.data)], True) -class IPMAWeather(WeatherEntity): +class IPMAWeather(WeatherEntity, IPMADevice): """Representation of a weather condition.""" _attr_native_pressure_unit = UnitOfPressure.HPA @@ -121,13 +89,14 @@ class IPMAWeather(WeatherEntity): def __init__(self, location: Location, api: IPMA_API, config) -> None: """Initialise the platform with a data instance and station name.""" + IPMADevice.__init__(self, location) self._api = api - self._location_name = config.get(CONF_NAME, location.name) + self._attr_name = config.get(CONF_NAME, location.name) self._mode = config.get(CONF_MODE) self._period = 1 if config.get(CONF_MODE) == "hourly" else 24 - self._location = location self._observation = None self._forecast: list[Forecast] = [] + self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}" @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: @@ -153,19 +122,6 @@ class IPMAWeather(WeatherEntity): self._observation, ) - @property - def unique_id(self) -> str: - """Return a unique id.""" - return ( - f"{self._location.station_latitude}, {self._location.station_longitude}," - f" {self._mode}" - ) - - @property - def name(self): - """Return the name of the station.""" - return self._location_name - def _condition_conversion(self, identifier, forecast_dt): """Convert from IPMA weather_type id to HA.""" if identifier == 1 and not is_up(self.hass, forecast_dt): diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index e93f9832722..59b8b4b070e 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.13.0"], + "requirements": ["pyipp==0.14.0"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/islamic_prayer_times/manifest.json b/homeassistant/components/islamic_prayer_times/manifest.json index 7e68ba9b24d..c87cb2d28ac 100644 --- a/homeassistant/components/islamic_prayer_times/manifest.json +++ b/homeassistant/components/islamic_prayer_times/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/islamic_prayer_times", "iot_class": "cloud_polling", "loggers": ["prayer_times_calculator"], - "requirements": ["prayer_times_calculator==0.0.6"] + "requirements": ["prayer-times-calculator==0.0.6"] } diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 686ffdb72f3..70f0f49d7a1 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -15,15 +15,18 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, CURRENCY_CENT, CURRENCY_DOLLAR, DEGREE, LIGHT_LUX, PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, REVOLUTIONS_PER_MINUTE, SERVICE_LOCK, SERVICE_UNLOCK, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, STATE_CLOSED, STATE_CLOSING, STATE_LOCKED, @@ -36,6 +39,7 @@ from homeassistant.const import ( STATE_UNLOCKED, UV_INDEX, Platform, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -399,7 +403,7 @@ UOM_FRIENDLY_NAME = { "92": f"{DEGREE} South", UOM_8_BIT_RANGE: "", # Range 0-255, no unit. UOM_DOUBLE_TEMP: UOM_DOUBLE_TEMP, - "102": "kWs", + "102": "kWs", # Kilowatt Seconds "103": CURRENCY_DOLLAR, "104": CURRENCY_CENT, "105": UnitOfLength.INCHES, @@ -417,6 +421,29 @@ UOM_FRIENDLY_NAME = { "118": UnitOfPressure.HPA, "119": UnitOfEnergy.WATT_HOUR, "120": UnitOfVolumetricFlux.INCHES_PER_DAY, + "122": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # Microgram per cubic meter + "123": f"bq/{UnitOfVolume.CUBIC_METERS}", # Becquerel per cubic meter + "124": f"pCi/{UnitOfVolume.LITERS}", # Picocuries per liter + "125": "pH", + "126": "bpm", # Beats per Minute + "127": UnitOfPressure.MMHG, + "128": "J", + "129": "BMI", # Body Mass Index + "130": f"{UnitOfVolume.LITERS}/{UnitOfTime.HOURS}", + "131": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + "132": "bpm", # Breaths per minute + "133": UnitOfFrequency.KILOHERTZ, + "134": f"{UnitOfLength.METERS}/{UnitOfTime.SECONDS}²", + "135": UnitOfApparentPower.VOLT_AMPERE, # Volt-Amp + "136": POWER_VOLT_AMPERE_REACTIVE, # VAR = Volt-Amp Reactive + "137": "", # NTP DateTime - Number of seconds since 1900 + "138": UnitOfPressure.PSI, + "139": DEGREE, # Degree 0-360 + "140": f"{UnitOfMass.MILLIGRAMS}/{UnitOfVolume.LITERS}", + "141": "N", # Netwon + "142": f"{UnitOfVolume.GALLONS}/{UnitOfTime.SECONDS}", + "143": "gpm", # Gallon per Minute + "144": "gph", # Gallon per Hour } UOM_TO_STATES = { diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index d2b10ef7419..60e2111848d 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -76,10 +76,9 @@ async def async_setup_entry( options = RAMP_RATE_OPTIONS elif control == CMD_BACKLIGHT: options = BACKLIGHT_INDEX - else: - if uom := node.aux_properties[control].uom == UOM_INDEX: - if options_dict := UOM_TO_STATES.get(uom): - options = list(options_dict.values()) + elif uom := node.aux_properties[control].uom == UOM_INDEX: + if options_dict := UOM_TO_STATES.get(uom): + options = list(options_dict.values()) description = SelectEntityDescription( key=f"{node.address}_{control}", diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index a150c052678..62ae375736d 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -1,6 +1,7 @@ """Support for ISY switches.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any from pyisy.constants import ( @@ -22,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -30,6 +31,15 @@ from .entity import ISYAuxControlEntity, ISYNodeEntity, ISYProgramEntity from .models import IsyData +@dataclass +class ISYSwitchEntityDescription(SwitchEntityDescription): + """Describes IST switch.""" + + # ISYEnableSwitchEntity does not support UNDEFINED or None, + # restrict the type to str. + name: str = "" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -53,7 +63,7 @@ async def async_setup_entry( for node, control in isy_data.aux_properties[Platform.SWITCH]: # Currently only used for enable switches, will need to be updated for # NS support by making sure control == TAG_ENABLED - description = SwitchEntityDescription( + description = ISYSwitchEntityDescription( key=control, device_class=SwitchDeviceClass.SWITCH, name=control.title(), @@ -135,7 +145,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): node: Node, control: str, unique_id: str, - description: EntityDescription, + description: ISYSwitchEntityDescription, device_info: DeviceInfo | None, ) -> None: """Initialize the ISY Aux Control Number entity.""" diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 2025e1a2a6c..bcd8e975823 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -90,6 +90,7 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): sw_version=self.app_version, via_device=(DOMAIN, coordinator.server_id), ) + self._attr_name = None else: self._attr_device_info = None self._attr_has_entity_name = False diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index b2e7e1468fd..f9c73443d00 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -189,7 +189,7 @@ class JellyfinSource(MediaSource): async def _build_artists(self, library_id: str) -> list[BrowseMediaSource]: """Return all artists in the music library.""" artists = await self._get_children(library_id, ITEM_TYPE_ARTIST) - artists = sorted(artists, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + artists = sorted(artists, key=lambda k: k[ITEM_KEY_NAME]) return [await self._build_artist(artist, False) for artist in artists] async def _build_artist( @@ -220,7 +220,7 @@ class JellyfinSource(MediaSource): async def _build_albums(self, parent_id: str) -> list[BrowseMediaSource]: """Return all albums of a single artist as browsable media sources.""" albums = await self._get_children(parent_id, ITEM_TYPE_ALBUM) - albums = sorted(albums, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + albums = sorted(albums, key=lambda k: k[ITEM_KEY_NAME]) return [await self._build_album(album, False) for album in albums] async def _build_album( @@ -310,7 +310,7 @@ class JellyfinSource(MediaSource): async def _build_movies(self, library_id: str) -> list[BrowseMediaSource]: """Return all movies in the movie library.""" movies = await self._get_children(library_id, ITEM_TYPE_MOVIE) - movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) return [ self._build_movie(movie) for movie in movies @@ -363,7 +363,7 @@ class JellyfinSource(MediaSource): async def _build_tvshow(self, library_id: str) -> list[BrowseMediaSource]: """Return all series in the tv library.""" series = await self._get_children(library_id, ITEM_TYPE_SERIES) - series = sorted(series, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + series = sorted(series, key=lambda k: k[ITEM_KEY_NAME]) return [await self._build_series(serie, False) for serie in series] async def _build_series( @@ -394,7 +394,7 @@ class JellyfinSource(MediaSource): async def _build_seasons(self, series_id: str) -> list[BrowseMediaSource]: """Return all seasons in the series.""" seasons = await self._get_children(series_id, ITEM_TYPE_SEASON) - seasons = sorted(seasons, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + seasons = sorted(seasons, key=lambda k: k[ITEM_KEY_NAME]) return [await self._build_season(season, False) for season in seasons] async def _build_season( @@ -425,7 +425,7 @@ class JellyfinSource(MediaSource): async def _build_episodes(self, season_id: str) -> list[BrowseMediaSource]: """Return all episode in the season.""" episodes = await self._get_children(season_id, ITEM_TYPE_EPISODE) - episodes = sorted(episodes, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + episodes = sorted(episodes, key=lambda k: k[ITEM_KEY_NAME]) return [ self._build_episode(episode) for episode in episodes diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index 1957adfc6eb..cd0e9ab21a2 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -42,6 +42,7 @@ def _count_now_playing(data: JellyfinDataT) -> int: SENSOR_TYPES: dict[str, JellyfinSensorEntityDescription] = { "sessions": JellyfinSensorEntityDescription( key="watching", + name=None, icon="mdi:television-play", native_unit_of_measurement="Watching", value_fn=_count_now_playing, diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index e33eef74c48..45f797a5aaa 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -52,6 +52,8 @@ async def async_setup_entry( class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity): """Representation of a JVC Projector device.""" + _attr_name = None + @property def is_on(self) -> bool: """Return True if entity is on.""" diff --git a/homeassistant/components/kaleidescape/entity.py b/homeassistant/components/kaleidescape/entity.py index 9a5e62bca94..cab55c20c02 100644 --- a/homeassistant/components/kaleidescape/entity.py +++ b/homeassistant/components/kaleidescape/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo, Entity @@ -28,7 +28,9 @@ class KaleidescapeEntity(Entity): self._attr_name = f"{KALEIDESCAPE_NAME} {device.system.friendly_name}" self._attr_device_info = DeviceInfo( identifiers={(KALEIDESCAPE_DOMAIN, self._device.serial_number)}, - name=self.name, + # Instead of setting the device name to the entity name, kaleidescape + # should be updated to set has_entity_name = True + name=cast(str | None, self.name), model=self._device.system.type, manufacturer=KALEIDESCAPE_NAME, sw_version=f"{self._device.system.kos_version}", diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index 41a1d0f2a2f..0751b40acd2 100644 --- a/homeassistant/components/keenetic_ndms2/manifest.json +++ b/homeassistant/components/keenetic_ndms2/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2", "iot_class": "local_polling", "loggers": ["ndms2_client"], - "requirements": ["ndms2_client==0.1.2"], + "requirements": ["ndms2-client==0.1.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index e3d280c2944..df3b6f0e427 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -203,22 +203,29 @@ class KeyboardRemote: try: async for event in self.inotify: descriptor = f"{DEVINPUT}/{event.name}" - _LOGGER.debug("got events for %s: %s", descriptor, event.mask) + _LOGGER.debug( + "got event for %s: %s", + descriptor, + event.mask, + ) descriptor_active = descriptor in self.active_handlers_by_descriptor if (event.mask & Mask.DELETE) and descriptor_active: + _LOGGER.debug("removing: %s", descriptor) handler = self.active_handlers_by_descriptor[descriptor] del self.active_handlers_by_descriptor[descriptor] await handler.async_device_stop_monitoring() elif ( (event.mask & Mask.CREATE) or (event.mask & Mask.ATTRIB) ) and not descriptor_active: + _LOGGER.debug("checking new: %s", descriptor) dev, handler = await self.hass.async_add_executor_job( self.get_device_handler, descriptor ) if handler is None: continue + _LOGGER.debug("adding: %s", descriptor) self.active_handlers_by_descriptor[descriptor] = handler await handler.async_device_start_monitoring(dev) except asyncio.CancelledError: @@ -244,8 +251,10 @@ class KeyboardRemote: self.emulate_key_hold_repeat = dev_block[EMULATE_KEY_HOLD_REPEAT] self.monitor_task = None self.dev = None + self.config_descriptor = dev_block.get(DEVICE_DESCRIPTOR) + self.descriptor = None - async def async_device_keyrepeat(self, path, name, code, delay, repeat): + async def async_device_keyrepeat(self, code, delay, repeat): """Emulate keyboard delay/repeat behaviour by sending key events on a timer.""" await asyncio.sleep(delay) @@ -255,8 +264,8 @@ class KeyboardRemote: { KEY_CODE: code, TYPE: "key_hold", - DEVICE_DESCRIPTOR: path, - DEVICE_NAME: name, + DEVICE_DESCRIPTOR: self.descriptor, + DEVICE_NAME: self.dev.name, }, ) await asyncio.sleep(repeat) @@ -266,12 +275,21 @@ class KeyboardRemote: _LOGGER.debug("Keyboard async_device_start_monitoring, %s", dev.name) if self.monitor_task is None: self.dev = dev + # set the descriptor to the one provided to the config if any, falling back to the device path if not set + if self.config_descriptor: + self.descriptor = self.config_descriptor + else: + self.descriptor = self.dev.path + self.monitor_task = self.hass.async_create_task( - self.async_monitor_input(dev) + self.async_device_monitor_input() ) self.hass.bus.async_fire( KEYBOARD_REMOTE_CONNECTED, - {DEVICE_DESCRIPTOR: dev.path, DEVICE_NAME: dev.name}, + { + DEVICE_DESCRIPTOR: self.descriptor, + DEVICE_NAME: dev.name, + }, ) _LOGGER.debug("Keyboard (re-)connected, %s", dev.name) @@ -291,12 +309,16 @@ class KeyboardRemote: self.monitor_task = None self.hass.bus.async_fire( KEYBOARD_REMOTE_DISCONNECTED, - {DEVICE_DESCRIPTOR: self.dev.path, DEVICE_NAME: self.dev.name}, + { + DEVICE_DESCRIPTOR: self.descriptor, + DEVICE_NAME: self.dev.name, + }, ) _LOGGER.debug("Keyboard disconnected, %s", self.dev.name) self.dev = None + self.descriptor = self.config_descriptor - async def async_monitor_input(self, dev): + async def async_device_monitor_input(self): """Event monitoring loop. Monitor one device for new events using evdev with asyncio, @@ -307,19 +329,22 @@ class KeyboardRemote: try: _LOGGER.debug("Start device monitoring") - await self.hass.async_add_executor_job(dev.grab) - async for event in dev.async_read_loop(): + await self.hass.async_add_executor_job(self.dev.grab) + async for event in self.dev.async_read_loop(): # pylint: disable=no-member if event.type is ecodes.EV_KEY: if event.value in self.key_values: - _LOGGER.debug(categorize(event)) + _LOGGER.debug( + "device: %s: %s", self.dev.name, categorize(event) + ) + self.hass.bus.async_fire( KEYBOARD_REMOTE_COMMAND_RECEIVED, { KEY_CODE: event.code, TYPE: KEY_VALUE_NAME[event.value], - DEVICE_DESCRIPTOR: dev.path, - DEVICE_NAME: dev.name, + DEVICE_DESCRIPTOR: self.descriptor, + DEVICE_NAME: self.dev.name, }, ) @@ -329,8 +354,6 @@ class KeyboardRemote: ): repeat_tasks[event.code] = self.hass.async_create_task( self.async_device_keyrepeat( - dev.path, - dev.name, event.code, self.emulate_key_hold_delay, self.emulate_key_hold_repeat, diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index 2b298901ca9..bb84b32defc 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -3,6 +3,7 @@ "name": "Keyboard Remote", "codeowners": ["@bendavid", "@lanrat"], "documentation": "https://www.home-assistant.io/integrations/keyboard_remote", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["aionotify", "evdev"], "requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"] diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 39143c8b84b..7857e6b3149 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -26,7 +26,7 @@ import homeassistant.util.dt as dt_util DOMAIN = "kitchen_sink" -COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK] +COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK, Platform.IMAGE] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/kitchen_sink/image.py b/homeassistant/components/kitchen_sink/image.py new file mode 100644 index 00000000000..4fe20f08de9 --- /dev/null +++ b/homeassistant/components/kitchen_sink/image.py @@ -0,0 +1,68 @@ +"""Demo image platform.""" +from __future__ import annotations + +from pathlib import Path + +from homeassistant.components.image import ImageEntity +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.util import dt as dt_util + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up image entities.""" + async_add_entities( + [ + DemoImage( + hass, + "kitchen_sink_image_001", + "QR Code", + "image/png", + "qr_code.png", + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Everything but the Kitchen Sink config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoImage(ImageEntity): + """Representation of an image entity.""" + + def __init__( + self, + hass: HomeAssistant, + unique_id: str, + name: str, + content_type: str, + image: str, + ) -> None: + """Initialize the image entity.""" + super().__init__(hass) + self._attr_content_type = content_type + self._attr_name = name + self._attr_unique_id = unique_id + self._image_filename = image + + async def async_added_to_hass(self): + """Set the update time.""" + self._attr_image_last_updated = dt_util.utcnow() + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + image_path = Path(__file__).parent / self._image_filename + return await self.hass.async_add_executor_job(image_path.read_bytes) diff --git a/homeassistant/components/kitchen_sink/lock.py b/homeassistant/components/kitchen_sink/lock.py index 343190acb63..b25941cf1a3 100644 --- a/homeassistant/components/kitchen_sink/lock.py +++ b/homeassistant/components/kitchen_sink/lock.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, STATE_UNLOCKING +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -17,7 +17,7 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo sensors.""" + """Set up the Demo locks.""" async_add_entities( [ DemoLock( @@ -70,6 +70,8 @@ class DemoLock(LockEntity): self._attr_unique_id = unique_id self._attr_supported_features = features self._state = state + self._attr_is_locking = False + self._attr_is_unlocking = False @property def is_locked(self) -> bool: @@ -78,12 +80,18 @@ class DemoLock(LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" + self._attr_is_locking = True + self.async_write_ha_state() + self._attr_is_locking = False self._state = STATE_LOCKED self.async_write_ha_state() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - self._state = STATE_UNLOCKING + self._attr_is_unlocking = True + self.async_write_ha_state() + self._attr_is_unlocking = False + self._state = STATE_UNLOCKED self.async_write_ha_state() async def async_open(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/kitchen_sink/qr_code.png b/homeassistant/components/kitchen_sink/qr_code.png new file mode 100644 index 00000000000..d8350728b63 Binary files /dev/null and b/homeassistant/components/kitchen_sink/qr_code.png differ diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 6692f53810b..6912c940482 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_BATTERY_LEVEL, UnitOfPower +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -31,7 +31,6 @@ async def async_setup_entry( None, SensorStateClass.MEASUREMENT, UnitOfPower.WATT, # Not a volume unit - None, ), DemoSensor( "statistics_issue_2", @@ -40,7 +39,6 @@ async def async_setup_entry( None, SensorStateClass.MEASUREMENT, "dogs", # Can't be converted to cats - None, ), DemoSensor( "statistics_issue_3", @@ -49,7 +47,6 @@ async def async_setup_entry( None, None, # Wrong state class UnitOfPower.WATT, - None, ), ] ) @@ -68,9 +65,6 @@ class DemoSensor(SensorEntity): device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit_of_measurement: str | None, - battery: StateType, - options: list[str] | None = None, - translation_key: str | None = None, ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class @@ -79,13 +73,8 @@ class DemoSensor(SensorEntity): self._attr_native_value = state self._attr_state_class = state_class self._attr_unique_id = unique_id - self._attr_options = options - self._attr_translation_key = translation_key self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, name=name, ) - - if battery: - self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery} diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 8a8e87b893f..e8c237114b5 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -30,6 +30,7 @@ from homeassistant.const import ( CONF_PORT, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall @@ -90,6 +91,7 @@ from .schema import ( SensorSchema, SwitchSchema, TextSchema, + TimeSchema, WeatherSchema, ga_validator, sensor_type_validator, @@ -143,6 +145,7 @@ CONFIG_SCHEMA = vol.Schema( **SensorSchema.platform_node(), **SwitchSchema.platform_node(), **TextSchema.platform_node(), + **TimeSchema.platform_node(), **WeatherSchema.platform_node(), } ), @@ -310,6 +313,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, ) + async def _reload_integration(call: ServiceCall) -> None: + """Reload the integration.""" + await hass.config_entries.async_reload(entry.entry_id) + hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) + + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_integration) + await register_panel(hass) return True @@ -683,6 +693,7 @@ class KNXModule: payload=GroupValueResponse(payload) if attr_response else GroupValueWrite(payload), + source_address=self.xknx.current_address, ) await self.xknx.telegrams.put(telegram) @@ -692,5 +703,6 @@ class KNXModule: telegram = Telegram( destination_address=parse_device_group_address(address), payload=GroupValueRead(), + source_address=self.xknx.current_address, ) await self.xknx.telegrams.put(telegram) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 5546a2d6fd9..a9f5341fbfd 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -114,24 +114,11 @@ class KNXConfigEntryData(TypedDict, total=False): telegram_log_size: int # not required -class KNXBusMonitorMessage(TypedDict): - """KNX bus monitor message.""" - - destination_address: str - destination_text: str | None - payload: str - type: str - value: str | None - source_address: str - source_text: str | None - direction: str - timestamp: str - - class ColorTempModes(Enum): """Color temperature modes for config validation.""" ABSOLUTE = "DPT-7.600" + ABSOLUTE_FLOAT = "DPT-9" RELATIVE = "DPT-5.001" @@ -149,6 +136,7 @@ SUPPORTED_PLATFORMS: Final = [ Platform.SENSOR, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.WEATHER, ] diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index f5ef8f61b84..07747f094c3 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -4,7 +4,11 @@ from __future__ import annotations from typing import Any, cast from xknx import XKNX -from xknx.devices.light import Light as XknxLight, XYYColor +from xknx.devices.light import ( + ColorTemperatureType, + Light as XknxLight, + XYYColor, +) from homeassistant import config_entries from homeassistant.components.light import ( @@ -56,16 +60,20 @@ def _create_light(xknx: XKNX, config: ConfigType) -> XknxLight: group_address_tunable_white_state = None group_address_color_temp = None group_address_color_temp_state = None - if config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.ABSOLUTE: - group_address_color_temp = config.get(LightSchema.CONF_COLOR_TEMP_ADDRESS) - group_address_color_temp_state = config.get( - LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS - ) - elif config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.RELATIVE: + color_temperature_type = ColorTemperatureType.UINT_2_BYTE + if config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.RELATIVE: group_address_tunable_white = config.get(LightSchema.CONF_COLOR_TEMP_ADDRESS) group_address_tunable_white_state = config.get( LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS ) + else: + # absolute uint or float + group_address_color_temp = config.get(LightSchema.CONF_COLOR_TEMP_ADDRESS) + group_address_color_temp_state = config.get( + LightSchema.CONF_COLOR_TEMP_STATE_ADDRESS + ) + if config[LightSchema.CONF_COLOR_TEMP_MODE] == ColorTempModes.ABSOLUTE_FLOAT: + color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE return XknxLight( xknx, @@ -140,6 +148,7 @@ def _create_light(xknx: XKNX, config: ConfigType) -> XknxLight: group_address_brightness_white_state=individual_color_addresses( LightSchema.CONF_WHITE, LightSchema.CONF_BRIGHTNESS_STATE_ADDRESS ), + color_temperature_type=color_temperature_type, min_kelvin=config[LightSchema.CONF_MIN_KELVIN], max_kelvin=config[LightSchema.CONF_MAX_KELVIN], ) @@ -239,7 +248,7 @@ class KNXLight(KnxEntity, LightEntity): """Return the color temperature in Kelvin.""" if self._device.supports_color_temperature: if kelvin := self._device.current_color_temperature: - return kelvin + return int(kelvin) if self._device.supports_tunable_white: relative_ct = self._device.current_tunable_white if relative_ct is not None: diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 1f0a6d3cc5e..30e239a65a9 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,8 +11,8 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.10.0", - "xknxproject==3.1.1", - "knx-frontend==2023.6.9.195839" + "xknx==2.11.1", + "xknxproject==3.2.0", + "knx-frontend==2023.6.23.191712" ] } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 0f627b724cb..86bf790a077 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -936,6 +936,25 @@ class TextSchema(KNXPlatformSchema): ) +class TimeSchema(KNXPlatformSchema): + """Voluptuous schema for KNX time.""" + + PLATFORM = Platform.TIME + + DEFAULT_NAME = "KNX Time" + + ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Required(KNX_ADDRESS): ga_list_validator, + vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + } + ) + + class WeatherSchema(KNXPlatformSchema): """Voluptuous schema for KNX weather station.""" diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index ea5ba2f63a6..4400c304193 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -71,7 +71,7 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( device_class=SensorDeviceClass.ENUM, options=[opt.value for opt in XknxConnectionType], should_poll=False, - value_fn=lambda knx: knx.xknx.connection_manager.connection_type.value, # type: ignore[no-any-return] + value_fn=lambda knx: knx.xknx.connection_manager.connection_type.value, ), KNXSystemEntityDescription( key="telegrams_incoming", diff --git a/homeassistant/components/knx/services.yaml b/homeassistant/components/knx/services.yaml index d95a1573872..0ad497a30a2 100644 --- a/homeassistant/components/knx/services.yaml +++ b/homeassistant/components/knx/services.yaml @@ -106,3 +106,6 @@ exposure_register: default: false selector: boolean: +reload: + name: Reload + description: Reload the KNX integration. diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 5b429b0bdc1..09307794066 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -20,9 +20,13 @@ from .project import KNXProject class TelegramDict(TypedDict): """Represent a Telegram as a dict.""" + # this has to be in sync with the frontend implementation destination: str destination_name: str direction: str + dpt_main: int | None + dpt_sub: int | None + dpt_name: str | None payload: int | tuple[int, ...] | None source: str source_name: str @@ -57,7 +61,7 @@ class Telegrams: async def _xknx_telegram_cb(self, telegram: Telegram) -> None: """Handle incoming and outgoing telegrams from xknx.""" telegram_dict = self.telegram_to_dict(telegram) - self.recent_telegrams.appendleft(telegram_dict) + self.recent_telegrams.append(telegram_dict) for job in self._jobs: self.hass.async_run_hass_job(job, telegram_dict) @@ -80,6 +84,9 @@ class Telegrams: def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: """Convert a Telegram to a dict.""" dst_name = "" + dpt_main = None + dpt_sub = None + dpt_name = None payload_data: int | tuple[int, ...] | None = None src_name = "" transcoder = None @@ -104,6 +111,9 @@ class Telegrams: if transcoder is not None: try: value = transcoder.from_knx(telegram.payload.value) + dpt_main = transcoder.dpt_main_number + dpt_sub = transcoder.dpt_sub_number + dpt_name = transcoder.value_type unit = transcoder.unit except XKNXException: value = "Error decoding value" @@ -112,6 +122,9 @@ class Telegrams: destination=f"{telegram.destination_address}", destination_name=dst_name, direction=telegram.direction.value, + dpt_main=dpt_main, + dpt_sub=dpt_sub, + dpt_name=dpt_name, payload=payload_data, source=f"{telegram.source_address}", source_name=src_name, diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py new file mode 100644 index 00000000000..af8ee48b806 --- /dev/null +++ b/homeassistant/components/knx/time.py @@ -0,0 +1,104 @@ +"""Support for KNX/IP time.""" +from __future__ import annotations + +from datetime import time as dt_time +import time as time_time +from typing import Final + +from xknx import XKNX +from xknx.devices import DateTime as XknxDateTime + +from homeassistant import config_entries +from homeassistant.components.time import TimeEntity +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_NAME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, +) +from .knx_entity import KnxEntity + +_TIME_TRANSLATION_FORMAT: Final = "%H:%M:%S" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for KNX platform.""" + xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.TIME] + + async_add_entities(KNXTime(xknx, entity_config) for entity_config in config) + + +def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: + """Return a XKNX DateTime object to be used within XKNX.""" + return XknxDateTime( + xknx, + name=config[CONF_NAME], + broadcast_type="TIME", + localtime=False, + group_address=config[KNX_ADDRESS], + group_address_state=config.get(CONF_STATE_ADDRESS), + respond_to_read=config[CONF_RESPOND_TO_READ], + sync_state=config[CONF_SYNC_STATE], + ) + + +class KNXTime(KnxEntity, TimeEntity, RestoreEntity): + """Representation of a KNX time.""" + + _device: XknxDateTime + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize a KNX time.""" + super().__init__(_create_xknx_device(xknx, config)) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = str(self._device.remote_value.group_address) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + not self._device.remote_value.readable + and (last_state := await self.async_get_last_state()) is not None + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + ): + self._device.remote_value.value = time_time.strptime( + last_state.state, _TIME_TRANSLATION_FORMAT + ) + + @property + def native_value(self) -> dt_time | None: + """Return the latest value.""" + if (time_struct := self._device.remote_value.value) is None: + return None + return dt_time( + hour=time_struct.tm_hour, + minute=time_struct.tm_min, + second=min(time_struct.tm_sec, 59), # account for leap seconds + ) + + async def async_set_value(self, value: dt_time) -> None: + """Change the value.""" + time_struct = time_time.strptime( + value.strftime(_TIME_TRANSLATION_FORMAT), + _TIME_TRANSLATION_FORMAT, + ) + await self._device.set(time_struct) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index a9da5036857..ad29fd19928 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -3,15 +3,14 @@ from __future__ import annotations from typing import TYPE_CHECKING, Final -from knx_frontend import entrypoint_js, is_dev_build, locate_dir +import knx_frontend as knx_panel import voluptuous as vol -from xknx.telegram import TelegramDirection from xknxproject.exceptions import XknxProjectException from homeassistant.components import panel_custom, websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, KNXBusMonitorMessage +from .const import DOMAIN from .telegrams import TelegramDict if TYPE_CHECKING: @@ -30,19 +29,18 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_subscribe_telegram) if DOMAIN not in hass.data.get("frontend_panels", {}): - path = locate_dir() hass.http.register_static_path( URL_BASE, - path, - cache_headers=not is_dev_build, + path=knx_panel.locate_dir(), + cache_headers=knx_panel.is_prod_build, ) await panel_custom.async_register_panel( hass=hass, frontend_url_path=DOMAIN, - webcomponent_name="knx-frontend", + webcomponent_name=knx_panel.webcomponent_name, sidebar_title=DOMAIN.upper(), sidebar_icon="mdi:bus-electric", - module_url=f"{URL_BASE}/{entrypoint_js()}", + module_url=f"{URL_BASE}/{knx_panel.entrypoint_js}", embed_iframe=True, require_admin=True, ) @@ -145,10 +143,7 @@ def ws_group_monitor_info( ) -> None: """Handle get info command of group monitor.""" knx: KNXModule = hass.data[DOMAIN] - recent_telegrams = [ - _telegram_dict_to_group_monitor(telegram) - for telegram in knx.telegrams.recent_telegrams - ] + recent_telegrams = [*knx.telegrams.recent_telegrams] connection.send_result( msg["id"], { @@ -178,7 +173,7 @@ def ws_subscribe_telegram( """Forward telegram to websocket subscription.""" connection.send_event( msg["id"], - _telegram_dict_to_group_monitor(telegram), + telegram, ) connection.subscriptions[msg["id"]] = knx.telegrams.async_listen_telegram( @@ -186,38 +181,3 @@ def ws_subscribe_telegram( name="KNX GroupMonitor subscription", ) connection.send_result(msg["id"]) - - -def _telegram_dict_to_group_monitor(telegram: TelegramDict) -> KNXBusMonitorMessage: - """Convert a TelegramDict to a KNXBusMonitorMessage object.""" - direction = ( - "group_monitor_incoming" - if telegram["direction"] == TelegramDirection.INCOMING.value - else "group_monitor_outgoing" - ) - - _payload = telegram["payload"] - if isinstance(_payload, tuple): - payload = f"0x{bytes(_payload).hex()}" - elif isinstance(_payload, int): - payload = f"{_payload:d}" - else: - payload = "" - - timestamp = telegram["timestamp"].strftime("%H:%M:%S.%f")[:-3] - - if (value := telegram["value"]) is not None: - unit = telegram["unit"] - value = f"{value}{' ' + unit if unit else ''}" - - return KNXBusMonitorMessage( - destination_address=telegram["destination"], - destination_text=telegram["destination_name"], - direction=direction, - payload=payload, - source_address=telegram["source"], - source_text=telegram["source_name"], - timestamp=timestamp, - type=telegram["telegramtype"], - value=value, - ) diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index c15c415bd9c..3f931d1e264 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -23,7 +23,7 @@ TRIGGER_TYPES = {"turn_on", "turn_off"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), } ) @@ -44,7 +44,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "turn_on", } ) @@ -53,7 +53,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: "turn_off", } ) @@ -69,15 +69,24 @@ def _attach_trigger( event_type, trigger_info: TriggerInfo, ): + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) trigger_data = trigger_info["trigger_data"] job = HassJob(action) @callback def _handle_event(event: Event): - if event.data[ATTR_ENTITY_ID] == config[CONF_ENTITY_ID]: + if event.data[ATTR_ENTITY_ID] == entity_id: hass.async_run_hass_job( job, - {"trigger": {**trigger_data, **config, "description": event_type}}, + { + "trigger": { + **trigger_data, + **config, + "description": event_type, + "entity_id": entity_id, + } + }, event.context, ) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 86788db6ae6..af4e5700805 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -259,6 +259,8 @@ def cmd( class KodiEntity(MediaPlayerEntity): """Representation of a XBMC/Kodi device.""" + _attr_has_entity_name = True + _attr_name = None _attr_supported_features = ( MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.NEXT_TRACK @@ -290,7 +292,11 @@ class KodiEntity(MediaPlayerEntity): self._media_position = None self._connect_error = False - self._attr_name = name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, uid)}, + manufacturer="Kodi", + name=name, + ) def _reset_state(self, players=None): self._players = players @@ -368,15 +374,6 @@ class KodiEntity(MediaPlayerEntity): """Return the unique id of the device.""" return self._unique_id - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Kodi", - name=self.name, - ) - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index fb2c60b32c9..7355a60f5f0 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -93,7 +93,7 @@ def setup_platform( _LOGGER.warning("Unable to open serial port: %s", exc) return - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: lacrosse.close()) # type: ignore[no-any-return] + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: lacrosse.close()) if CONF_JEELINK_LED in config: lacrosse.led_mode_state(config.get(CONF_JEELINK_LED)) diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 08179df5b7e..b4776b19c50 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -53,7 +53,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2023.12.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json index b9469f79e65..8dca67058b7 100644 --- a/homeassistant/components/laundrify/manifest.json +++ b/homeassistant/components/laundrify/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/laundrify", "iot_class": "cloud_polling", - "requirements": ["laundrify_aio==1.1.2"] + "requirements": ["laundrify-aio==1.1.2"] } diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index c0e46250c1e..bee6c0f0e29 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -1,11 +1,11 @@ { "device_automation": { "trigger_type": { - "transmitter": "transmitter code received", - "transponder": "transponder code received", - "fingerprint": "fingerprint code received", - "codelock": "code lock code received", - "send_keys": "send keys received" + "transmitter": "Transmitter code received", + "transponder": "Transponder code received", + "fingerprint": "Fingerprint code received", + "codelock": "Code lock code received", + "send_keys": "Send keys received" } } } diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 4716519ac18..6eaf2885d89 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble/", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==0.4.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.3.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index a19680ffa5c..cdc270f2e99 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble/", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==0.4.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.3.0", "led-ble==1.0.0"] } diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index c7a5281bf61..2b59e628705 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -40,6 +40,7 @@ SUPPORT_LGTV = ( | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.STOP ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -240,6 +241,10 @@ class LgTVDevice(MediaPlayerEntity): """Send media pause command to media player.""" self.send_command(LG_COMMAND.PAUSE) + def media_stop(self) -> None: + """Send media stop command to media player.""" + self.send_command(LG_COMMAND.STOP) + def media_next_track(self) -> None: """Send next track command.""" self.send_command(LG_COMMAND.FAST_FORWARD) diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 96ab2e5f7a2..1a2930c8051 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -70,7 +70,7 @@ class LidarrSensorEntityDescription( SENSOR_TYPES: dict[str, LidarrSensorEntityDescription[Any]] = { "disk_space": LidarrSensorEntityDescription( key="disk_space", - name="Disk space", + translation_key="disk_space", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", @@ -80,7 +80,7 @@ SENSOR_TYPES: dict[str, LidarrSensorEntityDescription[Any]] = { ), "queue": LidarrSensorEntityDescription[LidarrQueue]( key="queue", - name="Queue", + translation_key="queue", native_unit_of_measurement="Albums", icon="mdi:download", value_fn=lambda data, _: data.totalRecords, @@ -89,7 +89,7 @@ SENSOR_TYPES: dict[str, LidarrSensorEntityDescription[Any]] = { ), "wanted": LidarrSensorEntityDescription[LidarrQueue]( key="wanted", - name="Wanted", + translation_key="wanted", native_unit_of_measurement="Albums", icon="mdi:music", value_fn=lambda data, _: data.totalRecords, diff --git a/homeassistant/components/lidarr/strings.json b/homeassistant/components/lidarr/strings.json index ffa91c23f2a..bbe4b19db25 100644 --- a/homeassistant/components/lidarr/strings.json +++ b/homeassistant/components/lidarr/strings.json @@ -28,5 +28,18 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "disk_space": { + "name": "Disk space" + }, + "queue": { + "name": "Queue" + }, + "wanted": { + "name": "Wanted" + } + } } } diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index b5f5373b3e8..00d216351a0 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -24,7 +24,7 @@ RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( IDENTIFY_BUTTON_DESCRIPTION = ButtonEntityDescription( key=IDENTIFY, - name="Identify", + device_class=ButtonDeviceClass.IDENTIFY, entity_category=EntityCategory.CONFIG, ) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index e867bb65eb0..d6b253bd478 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -42,7 +42,7 @@ "quality_scale": "platinum", "requirements": [ "aiolifx==0.8.10", - "aiolifx_effects==0.3.2", - "aiolifx_themes==0.4.5" + "aiolifx-effects==0.3.2", + "aiolifx-themes==0.4.5" ] } diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 2b4c32cb3b1..2b49c963438 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -3,7 +3,11 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_get_entity_registry_entry_or_raise, + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -37,9 +41,9 @@ TYPE_BRIGHTNESS_INCREASE = "brightness_increase" TYPE_BRIGHTNESS_DECREASE = "brightness_decrease" TYPE_FLASH = "flash" -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_DOMAIN): DOMAIN, vol.Required(CONF_TYPE): vol.In( toggle_entity.DEVICE_ACTION_TYPES @@ -51,6 +55,13 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( ) +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_call_action_from_config( hass: HomeAssistant, config: ConfigType, @@ -102,7 +113,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } if brightness_supported(supported_color_modes): @@ -127,13 +138,11 @@ async def async_get_action_capabilities( return {} try: - supported_color_modes = get_supported_color_modes(hass, config[ATTR_ENTITY_ID]) + entry = async_get_entity_registry_entry_or_raise(hass, config[CONF_ENTITY_ID]) + supported_color_modes = get_supported_color_modes(hass, entry.entity_id) + supported_features = get_supported_features(hass, entry.entity_id) except HomeAssistantError: supported_color_modes = None - - try: - supported_features = get_supported_features(hass, config[ATTR_ENTITY_ID]) - except HomeAssistantError: supported_features = 0 extra_fields = {} diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 45483f99e5b..c7eda2f118b 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -18,7 +18,7 @@ PLATFORMS_BY_TYPE = { Platform.SWITCH, ), LitterRobot: (Platform.VACUUM,), - LitterRobot3: (Platform.BUTTON,), + LitterRobot3: (Platform.BUTTON, Platform.TIME), LitterRobot4: (Platform.UPDATE,), FeederRobot: (Platform.BUTTON,), } diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 107935be7b8..5308a3b4f83 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -51,7 +51,7 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . LitterRobot: ( RobotBinarySensorEntityDescription[LitterRobot]( key="sleeping", - name="Sleeping", + translation_key="sleeping", icon="mdi:sleep", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -59,7 +59,7 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . ), RobotBinarySensorEntityDescription[LitterRobot]( key="sleep_mode", - name="Sleep mode", + translation_key="sleep_mode", icon="mdi:sleep", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -69,7 +69,7 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . Robot: ( RobotBinarySensorEntityDescription[Robot]( key="power_status", - name="Power status", + translation_key="power_status", device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 1d208ca48e1..06c4fe75888 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -60,14 +60,14 @@ class RobotButtonEntityDescription(ButtonEntityDescription, RequiredKeysMixin[_R LITTER_ROBOT_BUTTON = RobotButtonEntityDescription[LitterRobot3]( key="reset_waste_drawer", - name="Reset waste drawer", + translation_key="reset_waste_drawer", icon="mdi:delete-variant", entity_category=EntityCategory.CONFIG, press_fn=lambda robot: robot.reset_waste_drawer(), ) FEEDER_ROBOT_BUTTON = RobotButtonEntityDescription[FeederRobot]( key="give_snack", - name="Give snack", + translation_key="give_snack", icon="mdi:candy-outline", press_fn=lambda robot: robot.give_snack(), ) diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index feac85ecac4..6fabd6ea526 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -50,7 +50,7 @@ class RobotSelectEntityDescription( ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( key="cycle_delay", - name="Clean cycle wait time minutes", + translation_key="cycle_delay", icon="mdi:timer-outline", unit_of_measurement=UnitOfTime.MINUTES, current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, @@ -59,7 +59,6 @@ ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { ), LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str]( key="panel_brightness", - name="Panel brightness", translation_key="brightness_level", current_fn=lambda robot: bri.name.lower() if (bri := robot.panel_brightness) is not None @@ -72,7 +71,7 @@ ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { ), FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( key="meal_insert_size", - name="Meal insert size", + translation_key="meal_insert_size", icon="mdi:scale", unit_of_measurement="cups", current_fn=lambda robot: robot.meal_insert_size, diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index e7aed366fa3..ba601a0ba54 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -69,32 +69,31 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { LitterRobot: [ RobotSensorEntityDescription[LitterRobot]( key="waste_drawer_level", - name="Waste drawer", + translation_key="waste_drawer", native_unit_of_measurement=PERCENTAGE, icon_fn=lambda state: icon_for_gauge_level(state, 10), state_class=SensorStateClass.MEASUREMENT, ), RobotSensorEntityDescription[LitterRobot]( key="sleep_mode_start_time", - name="Sleep mode start time", + translation_key="sleep_mode_start_time", device_class=SensorDeviceClass.TIMESTAMP, should_report=lambda robot: robot.sleep_mode_enabled, ), RobotSensorEntityDescription[LitterRobot]( key="sleep_mode_end_time", - name="Sleep mode end time", + translation_key="sleep_mode_end_time", device_class=SensorDeviceClass.TIMESTAMP, should_report=lambda robot: robot.sleep_mode_enabled, ), RobotSensorEntityDescription[LitterRobot]( key="last_seen", - name="Last seen", + translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, ), RobotSensorEntityDescription[LitterRobot]( key="status_code", - name="Status code", translation_key="status_code", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, @@ -130,14 +129,14 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { LitterRobot4: [ RobotSensorEntityDescription[LitterRobot4]( key="litter_level", - name="Litter level", + translation_key="litter_level", native_unit_of_measurement=PERCENTAGE, icon_fn=lambda state: icon_for_gauge_level(state, 10), state_class=SensorStateClass.MEASUREMENT, ), RobotSensorEntityDescription[LitterRobot4]( key="pet_weight", - name="Pet weight", + translation_key="pet_weight", native_unit_of_measurement=UnitOfMass.POUNDS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -146,7 +145,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { FeederRobot: [ RobotSensorEntityDescription[FeederRobot]( key="food_level", - name="Food level", + translation_key="food_level", native_unit_of_measurement=PERCENTAGE, icon_fn=lambda state: icon_for_gauge_level(state, 10), state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index b4aa8f0016d..00a8a6122db 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -32,8 +32,46 @@ } }, "entity": { + "binary_sensor": { + "sleeping": { + "name": "Sleeping" + }, + "sleep_mode": { + "name": "Sleep mode" + }, + "power_status": { + "name": "Power status" + } + }, + "button": { + "reset_waste_drawer": { + "name": "Reset waste drawer" + }, + "give_snack": { + "name": "Give snack" + } + }, "sensor": { + "food_level": { + "name": "Food level" + }, + "last_seen": { + "name": "Last seen" + }, + "litter_level": { + "name": "Litter level" + }, + "pet_weight": { + "name": "Pet weight" + }, + "sleep_mode_end_time": { + "name": "Sleep mode end time" + }, + "sleep_mode_start_time": { + "name": "Sleep mode start time" + }, "status_code": { + "name": "Status code", "state": { "br": "Bonnet Removed", "ccc": "Clean Cycle Complete", @@ -61,16 +99,49 @@ "sdf": "Drawer Full At Startup", "spf": "Pinch Detect At Startup" } + }, + "waste_drawer": { + "name": "Waste drawer" } }, "select": { + "cycle_delay": { + "name": "Clean cycle wait time minutes" + }, + "meal_insert_size": { + "name": "Meal insert size" + }, "brightness_level": { + "name": "Panel brightness", "state": { "low": "Low", "medium": "Medium", "high": "High" } } + }, + "switch": { + "night_light_mode": { + "name": "Night light mode" + }, + "panel_lockout": { + "name": "Panel lockout" + } + }, + "time": { + "sleep_mode_start_time": { + "name": "Sleep mode start time" + } + }, + "vacuum": { + "litter_box": { + "name": "Litter box" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } } } } diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index eb2297e506e..6b4e5b56b48 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -36,13 +36,13 @@ class RobotSwitchEntityDescription(SwitchEntityDescription, RequiredKeysMixin[_R ROBOT_SWITCHES = [ RobotSwitchEntityDescription[LitterRobot | FeederRobot]( key="night_light_mode_enabled", - name="Night light mode", + translation_key="night_light_mode", icons=("mdi:lightbulb-on", "mdi:lightbulb-off"), set_fn=lambda robot, value: robot.set_night_light(value), ), RobotSwitchEntityDescription[LitterRobot | FeederRobot]( key="panel_lock_enabled", - name="Panel lockout", + translation_key="panel_lockout", icons=("mdi:lock", "mdi:lock-open"), set_fn=lambda robot, value: robot.set_panel_lockout(value), ), diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py new file mode 100644 index 00000000000..f352b7cee70 --- /dev/null +++ b/homeassistant/components/litterrobot/time.py @@ -0,0 +1,82 @@ +"""Support for Litter-Robot time.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import datetime, time +from typing import Any, Generic + +from pylitterbot import LitterRobot3 + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .entity import LitterRobotEntity, _RobotT +from .hub import LitterRobotHub + + +@dataclass +class RequiredKeysMixin(Generic[_RobotT]): + """A class that describes robot time entity required keys.""" + + value_fn: Callable[[_RobotT], time | None] + set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]] + + +@dataclass +class RobotTimeEntityDescription(TimeEntityDescription, RequiredKeysMixin[_RobotT]): + """A class that describes robot time entities.""" + + +def _as_local_time(start: datetime | None) -> time | None: + """Return a datetime as local time.""" + return dt_util.as_local(start).time() if start else None + + +LITTER_ROBOT_3_SLEEP_START = RobotTimeEntityDescription[LitterRobot3]( + key="sleep_mode_start_time", + translation_key="sleep_mode_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda robot: _as_local_time(robot.sleep_mode_start_time), + set_fn=lambda robot, value: robot.set_sleep_mode( + robot.sleep_mode_enabled, value.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Litter-Robot cleaner using config entry.""" + hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + LitterRobotTimeEntity( + robot=robot, hub=hub, description=LITTER_ROBOT_3_SLEEP_START + ) + for robot in hub.litter_robots() + if isinstance(robot, LitterRobot3) + ] + ) + + +class LitterRobotTimeEntity(LitterRobotEntity[_RobotT], TimeEntity): + """Litter-Robot time entity.""" + + entity_description: RobotTimeEntityDescription[_RobotT] + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return self.entity_description.value_fn(self.robot) + + async def async_set_value(self, value: time) -> None: + """Update the current value.""" + await self.entity_description.set_fn(self.robot, value) diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index 33ca6cd0376..9b8391c5bae 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -24,7 +24,7 @@ SCAN_INTERVAL = timedelta(days=1) FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription( key="firmware", - name="Firmware", + translation_key="firmware", device_class=UpdateDeviceClass.FIRMWARE, ) diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index b56724f15c8..d1352c1e45f 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -42,7 +42,9 @@ LITTER_BOX_STATUS_STATE_MAP = { LitterBoxStatus.OFF: STATE_OFF, } -LITTER_BOX_ENTITY = StateVacuumEntityDescription("litter_box", name="Litter box") +LITTER_BOX_ENTITY = StateVacuumEntityDescription( + "litter_box", translation_key="litter_box" +) async def async_setup_entry( diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index 5a796b976ff..cca322f3baa 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -21,7 +21,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -61,12 +60,6 @@ WEBHOOK_SCHEMA = vol.All( ) -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Set up the Locative component.""" - hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} - return True - - async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook from Locative.""" try: @@ -117,6 +110,8 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Configure based on config entry.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} webhook.async_register( hass, DOMAIN, "Locative", entry.data[CONF_WEBHOOK_ID], handle_webhook ) diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index 01e7b21d4b6..fba95a932de 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -23,14 +24,21 @@ from . import DOMAIN, LockEntityFeature ACTION_TYPES = {"lock", "unlock", "open"} -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ) +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( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -49,7 +57,7 @@ async def async_get_actions( base_action = { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } actions.append({**base_action, CONF_TYPE: "lock"}) diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index c439fe99d14..5ba93554aec 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -39,7 +39,7 @@ CONDITION_TYPES = { CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -62,7 +62,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] @@ -86,8 +86,11 @@ def async_condition_from_config( else: state = STATE_UNLOCKED + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index ec996d4f0b2..c6b86eaca4a 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -29,7 +29,7 @@ TRIGGER_TYPES = {"locked", "unlocked", "locking", "unlocking", "jammed"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -54,7 +54,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: trigger, } for trigger in TRIGGER_TYPES diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index b1086d7f780..cd2761510d3 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -14,6 +14,7 @@ from . import websocket_api from .const import ( ATTR_LEVEL, DOMAIN, + EVENT_LOGGING_CHANGED, # noqa: F401 LOGGER_DEFAULT, LOGGER_FILTERS, LOGGER_LOGS, @@ -21,7 +22,6 @@ from .const import ( SERVICE_SET_DEFAULT_LEVEL, SERVICE_SET_LEVEL, ) -from .const import EVENT_LOGGING_CHANGED # noqa: F401 from .helpers import ( LoggerDomainConfig, LoggerSettings, diff --git a/homeassistant/components/logi_circle/manifest.json b/homeassistant/components/logi_circle/manifest.json index 2f08fe6f135..f4f65b22505 100644 --- a/homeassistant/components/logi_circle/manifest.json +++ b/homeassistant/components/logi_circle/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/logi_circle", "iot_class": "cloud_polling", "loggers": ["logi_circle"], - "requirements": ["logi_circle==0.2.3"] + "requirements": ["logi-circle==0.2.3"] } diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py new file mode 100644 index 00000000000..1248c75612f --- /dev/null +++ b/homeassistant/components/loqed/__init__.py @@ -0,0 +1,55 @@ +"""The loqed integration.""" +from __future__ import annotations + +import logging +import re + +from loqedAPI import loqed + +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 .const import DOMAIN +from .coordinator import LoqedDataCoordinator + +PLATFORMS: list[str] = [Platform.LOCK] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up loqed from a config entry.""" + websession = async_get_clientsession(hass) + host = entry.data["bridge_ip"] + 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"]), + ) + coordinator = LoqedDataCoordinator(hass, api, lock, entry) + await coordinator.ensure_webhooks() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await coordinator.async_config_entry_first_refresh() + + 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.""" + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][entry.entry_id] + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + await coordinator.remove_webhooks() + + return unload_ok diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py new file mode 100644 index 00000000000..5eecc0b3f59 --- /dev/null +++ b/homeassistant/components/loqed/config_flow.py @@ -0,0 +1,169 @@ +"""Config flow for loqed integration.""" +from __future__ import annotations + +import logging +import re +from typing import Any + +import aiohttp +from loqedAPI import cloud_loqed, loqed +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.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Loqed.""" + + VERSION = 1 + DOMAIN = DOMAIN + _host: str | None = None + + async def validate_input( + self, hass: HomeAssistant, data: dict[str, Any] + ) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + + # 1. Checking loqed-connection + try: + session = async_get_clientsession(hass) + cloud_api_client = cloud_loqed.CloudAPIClient( + session, + data[CONF_API_TOKEN], + ) + cloud_client = cloud_loqed.LoqedCloudAPI(cloud_api_client) + lock_data = await cloud_client.async_get_locks() + except aiohttp.ClientError as err: + _LOGGER.error("HTTP Connection error to loqed API") + raise CannotConnect from err + + try: + selected_lock = next( + lock + for lock in lock_data["data"] + if lock["bridge_ip"] == self._host or lock["name"] == data.get("name") + ) + + apiclient = loqed.APIClient(session, f"http://{selected_lock['bridge_ip']}") + api = loqed.LoqedAPI(apiclient) + lock = await api.async_get_lock( + selected_lock["backend_key"], + selected_lock["bridge_key"], + selected_lock["local_id"], + selected_lock["bridge_ip"], + ) + + # checking getWebooks to check the bridgeKey + await lock.getWebhooks() + return { + "lock_key_key": selected_lock["key_secret"], + "bridge_key": selected_lock["bridge_key"], + "lock_key_local_id": selected_lock["local_id"], + "bridge_mdns_hostname": selected_lock["bridge_hostname"], + "bridge_ip": selected_lock["bridge_ip"], + "name": selected_lock["name"], + "id": selected_lock["id"], + } + except StopIteration: + raise InvalidAuth from StopIteration + except aiohttp.ClientError: + _LOGGER.error("HTTP Connection error to loqed lock") + raise CannotConnect from aiohttp.ClientError + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + host = discovery_info.host + self._host = host + + session = async_get_clientsession(self.hass) + apiclient = loqed.APIClient(session, f"http://{host}") + api = loqed.LoqedAPI(apiclient) + lock_data = await api.async_get_lock_details() + + # Check if already exists + await self.async_set_unique_id(lock_data["bridge_mac_wifi"]) + self._abort_if_unique_id_configured({CONF_HOST: host}) + + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show userform to user.""" + user_data_schema = ( + vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } + ) + if self._host + else vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_API_TOKEN): str, + } + ) + ) + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=user_data_schema, + description_placeholders={ + "config_url": "https://integrations.production.loqed.com/personal-access-tokens", + }, + ) + + errors = {} + + try: + info = await self.validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id( + re.sub( + r"LOQED-([a-f0-9]+)\.local", r"\1", info["bridge_mdns_hostname"] + ), + raise_on_progress=False, + ) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="LOQED Touch Smart Lock", + data=( + user_input | {CONF_WEBHOOK_ID: webhook.async_generate_id()} | info + ), + ) + + return self.async_show_form( + step_id="user", + data_schema=user_data_schema, + errors=errors, + description_placeholders={ + "config_url": "https://integrations.production.loqed.com/personal-access-tokens", + }, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/loqed/const.py b/homeassistant/components/loqed/const.py new file mode 100644 index 00000000000..6b1c0311a2d --- /dev/null +++ b/homeassistant/components/loqed/const.py @@ -0,0 +1,4 @@ +"""Constants for the loqed integration.""" + + +DOMAIN = "loqed" diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py new file mode 100644 index 00000000000..507debc02ab --- /dev/null +++ b/homeassistant/components/loqed/coordinator.py @@ -0,0 +1,151 @@ +"""Provides the coordinator for a LOQED lock.""" +import logging +from typing import TypedDict + +from aiohttp.web import Request +import async_timeout +from loqedAPI import loqed + +from homeassistant.components import webhook +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class BatteryMessage(TypedDict): + """Properties in a battery update message.""" + + mac_wifi: str + mac_ble: str + battery_type: str + battery_percentage: int + + +class StateReachedMessage(TypedDict): + """Properties in a battery update message.""" + + requested_state: str + requested_state_numeric: int + event_type: str + key_local_id: int + mac_wifi: str + mac_ble: str + + +class TransitionMessage(TypedDict): + """Properties in a battery update message.""" + + go_to_state: str + go_to_state_numeric: int + event_type: str + key_local_id: int + mac_wifi: str + mac_ble: str + + +class StatusMessage(TypedDict): + """Properties returned by the status endpoint of the bridhge.""" + + battery_percentage: int + battery_type: str + battery_type_numeric: int + battery_voltage: float + bolt_state: str + bolt_state_numeric: int + bridge_mac_wifi: str + bridge_mac_ble: str + lock_online: int + webhooks_number: int + ip_address: str + up_timestamp: int + wifi_strength: int + ble_strength: int + + +class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): + """Data update coordinator for the loqed platform.""" + + def __init__( + self, + hass: HomeAssistant, + api: loqed.LoqedAPI, + lock: loqed.Lock, + entry: ConfigEntry, + ) -> None: + """Initialize the Loqed Data Update coordinator.""" + super().__init__(hass, _LOGGER, name="Loqed sensors") + self._api = api + self._entry = entry + self.lock = lock + self.device_name = self._entry.data[CONF_NAME] + + async def _async_update_data(self) -> StatusMessage: + """Fetch data from API endpoint.""" + async with async_timeout.timeout(10): + return await self._api.async_get_lock_details() + + async def _handle_webhook( + self, hass: HomeAssistant, webhook_id: str, request: Request + ) -> None: + """Handle incoming Loqed messages.""" + _LOGGER.debug("Callback received: %s", request.headers) + received_ts = request.headers["TIMESTAMP"] + received_hash = request.headers["HASH"] + body = await request.text() + + _LOGGER.debug("Callback body: %s", body) + + event_data = await self.lock.receiveWebhook(body, received_hash, received_ts) + if "error" in event_data: + _LOGGER.warning("Incorrect callback received:: %s", event_data) + return + + self.async_update_listeners() + + async def ensure_webhooks(self) -> None: + """Register webhook on LOQED bridge.""" + webhook_id = self._entry.data[CONF_WEBHOOK_ID] + + webhook.async_register( + self.hass, DOMAIN, "Loqed", webhook_id, self._handle_webhook + ) + webhook_url = webhook.async_generate_url(self.hass, webhook_id) + _LOGGER.debug("Webhook URL: %s", webhook_url) + + webhooks = await self.lock.getWebhooks() + + webhook_index = next( + (x["id"] for x in webhooks if x["url"] == webhook_url), None + ) + + if not webhook_index: + await self.lock.registerWebhook(webhook_url) + webhooks = await self.lock.getWebhooks() + webhook_index = next(x["id"] for x in webhooks if x["url"] == webhook_url) + + _LOGGER.info("Webhook got index %s", webhook_index) + + async def remove_webhooks(self) -> None: + """Remove webhook from LOQED bridge.""" + webhook_id = self._entry.data[CONF_WEBHOOK_ID] + webhook_url = webhook.async_generate_url(self.hass, webhook_id) + + webhook.async_unregister( + self.hass, + webhook_id, + ) + _LOGGER.info("Webhook URL: %s", webhook_url) + + webhooks = await self.lock.getWebhooks() + + webhook_index = next( + (x["id"] for x in webhooks if x["url"] == webhook_url), None + ) + + if webhook_index: + await self.lock.deleteWebhook(webhook_index) diff --git a/homeassistant/components/loqed/entity.py b/homeassistant/components/loqed/entity.py new file mode 100644 index 00000000000..978fe844d61 --- /dev/null +++ b/homeassistant/components/loqed/entity.py @@ -0,0 +1,29 @@ +"""Base entity for the LOQED integration.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LoqedDataCoordinator + + +class LoqedEntity(CoordinatorEntity[LoqedDataCoordinator]): + """Defines a LOQED entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: LoqedDataCoordinator) -> None: + """Initialize the LOQED entity.""" + super().__init__(coordinator=coordinator) + + lock_id = coordinator.lock.id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, lock_id)}, + manufacturer="LOQED", + name=coordinator.device_name, + model="Touch Smart Lock", + connections={(CONNECTION_NETWORK_MAC, lock_id)}, + ) diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py new file mode 100644 index 00000000000..d34df19e2d1 --- /dev/null +++ b/homeassistant/components/loqed/lock.py @@ -0,0 +1,85 @@ +"""LOQED lock integration for Home Assistant.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LoqedDataCoordinator +from .const import DOMAIN +from .entity import LoqedEntity + +WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Loqed lock platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([LoqedLock(coordinator)]) + + +class LoqedLock(LoqedEntity, LockEntity): + """Representation of a loqed lock.""" + + _attr_supported_features = LockEntityFeature.OPEN + + def __init__(self, coordinator: LoqedDataCoordinator) -> None: + """Initialize the lock.""" + super().__init__(coordinator) + self._lock = coordinator.lock + self._attr_unique_id = self._lock.id + self._attr_name = None + + @property + def changed_by(self) -> str: + """Return internal ID of last used key.""" + return f"KeyID {self._lock.last_key_id}" + + @property + def is_locking(self) -> bool | None: + """Return true if lock is locking.""" + return self._lock.bolt_state == "locking" + + @property + def is_unlocking(self) -> bool | None: + """Return true if lock is unlocking.""" + return self._lock.bolt_state == "unlocking" + + @property + def is_jammed(self) -> bool | None: + """Return true if lock is jammed.""" + return self._lock.bolt_state == "motor_stall" + + @property + def is_locked(self) -> bool | None: + """Return true if lock is locked.""" + return self._lock.bolt_state in ["night_lock_remote", "night_lock"] + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + await self._lock.lock() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock.""" + await self._lock.unlock() + + async def async_open(self, **kwargs: Any) -> None: + """Open the door latch.""" + await self._lock.open() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug(self.coordinator.data) + if "bolt_state" in self.coordinator.data: + self._lock.updateState(self.coordinator.data["bolt_state"]).close() + self.async_write_ha_state() diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json new file mode 100644 index 00000000000..1000d8f804d --- /dev/null +++ b/homeassistant/components/loqed/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "loqed", + "name": "LOQED Touch Smart Lock", + "codeowners": ["@mikewoudenberg"], + "config_flow": true, + "dependencies": ["webhook"], + "documentation": "https://www.home-assistant.io/integrations/loqed", + "iot_class": "local_push", + "requirements": ["loqedAPI==2.1.7"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "loqed*" + } + ] +} diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json new file mode 100644 index 00000000000..6f3316b283f --- /dev/null +++ b/homeassistant/components/loqed/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "flow_title": "LOQED Touch Smartlock setup", + "step": { + "user": { + "description": "Login at {config_url} and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.", + "data": { + "name": "Name of your lock in the LOQED app.", + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 262a6701f56..cca467ce756 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -32,21 +32,18 @@ from .const import ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", - translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="humidity", - translation_key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pressure", - translation_key="pressure", native_unit_of_measurement=UnitOfPressure.PA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, @@ -60,14 +57,12 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="P1", - translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="P2", - translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json index d54bc6d0bdc..e990142923f 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -16,22 +16,7 @@ }, "entity": { "sensor": { - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, - "pressure": { - "name": "[%key:component::sensor::entity_component::pressure::name%]" - }, - "pressure_at_sealevel": { "name": "Pressure at sealevel" }, - "pm10": { - "name": "[%key:component::sensor::entity_component::pm10::name%]" - }, - "pm25": { - "name": "[%key:component::sensor::entity_component::pm25::name%]" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - } + "pressure_at_sealevel": { "name": "Pressure at sealevel" } } } } diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index d8ccce8a6bc..c15f0ea075e 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -37,6 +37,7 @@ LUTRON_DEVICES = "lutron_devices" # Attribute on events that indicates what action was taken with the button. ATTR_ACTION = "action" ATTR_FULL_ID = "full_id" +ATTR_UUID = "uuid" CONFIG_SCHEMA = vol.Schema( { @@ -170,6 +171,7 @@ class LutronButton: self._button = button self._event = "lutron_event" self._full_id = slugify(f"{area_name} {name}") + self._uuid = button.uuid button.subscribe(self.button_callback, None) @@ -188,5 +190,10 @@ class LutronButton: action = "single" if action: - data = {ATTR_ID: self._id, ATTR_ACTION: action, ATTR_FULL_ID: self._full_id} + data = { + ATTR_ID: self._id, + ATTR_ACTION: action, + ATTR_FULL_ID: self._full_id, + ATTR_UUID: self._uuid, + } self._hass.bus.fire(self._event, data) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index f34366f24d0..c2423a7c47f 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -23,8 +23,6 @@ from homeassistant.helpers import ( device_registry as dr, ) from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -38,29 +36,13 @@ from .api import ( ) from .const import DOMAIN -CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Honeywell Lyric integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Honeywell Lyric from a config entry.""" implementation = ( diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 29f023d0de2..75cea546b71 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -16,7 +16,11 @@ from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import ( + config_per_platform, + config_validation as cv, + discovery, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -32,6 +36,8 @@ CONTENT_TYPE_NONE: Final = "none" SCAN_INTERVAL = timedelta(seconds=30) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for mailboxes.""" diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 9f16dae8334..8e76706b7fd 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -52,15 +52,68 @@ class MatterAdapter: async def setup_nodes(self) -> None: """Set up all existing nodes and subscribe to new nodes.""" - for node in await self.matter_client.get_nodes(): + for node in self.matter_client.get_nodes(): self._setup_node(node) def node_added_callback(event: EventType, node: MatterNode) -> None: """Handle node added event.""" self._setup_node(node) + def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None: + """Handle endpoint added event.""" + node = self.matter_client.get_node(data["node_id"]) + self._setup_endpoint(node.endpoints[data["endpoint_id"]]) + + def endpoint_removed_callback(event: EventType, data: dict[str, int]) -> None: + """Handle endpoint removed event.""" + server_info = cast(ServerInfoMessage, self.matter_client.server_info) + try: + node = self.matter_client.get_node(data["node_id"]) + except KeyError: + return # race condition + device_registry = dr.async_get(self.hass) + endpoint = node.endpoints.get(data["endpoint_id"]) + if not endpoint: + return # race condition + node_device_id = get_device_id( + server_info, + node.endpoints[data["endpoint_id"]], + ) + identifier = (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}") + if device := device_registry.async_get_device({identifier}): + device_registry.async_remove_device(device.id) + + def node_removed_callback(event: EventType, node_id: int) -> None: + """Handle node removed event.""" + try: + node = self.matter_client.get_node(node_id) + except KeyError: + return # race condition + for endpoint_id in node.endpoints: + endpoint_removed_callback( + EventType.ENDPOINT_REMOVED, + {"node_id": node_id, "endpoint_id": endpoint_id}, + ) + self.config_entry.async_on_unload( - self.matter_client.subscribe(node_added_callback, EventType.NODE_ADDED) + self.matter_client.subscribe_events( + endpoint_added_callback, EventType.ENDPOINT_ADDED + ) + ) + self.config_entry.async_on_unload( + self.matter_client.subscribe_events( + endpoint_removed_callback, EventType.ENDPOINT_REMOVED + ) + ) + self.config_entry.async_on_unload( + self.matter_client.subscribe_events( + node_removed_callback, EventType.NODE_REMOVED + ) + ) + self.config_entry.async_on_unload( + self.matter_client.subscribe_events( + node_added_callback, EventType.NODE_ADDED + ) ) def _setup_node(self, node: MatterNode) -> None: diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index bd65b3a0925..7c94c07c8cd 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -65,7 +65,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="HueMotionSensor", device_class=BinarySensorDeviceClass.MOTION, - name="Motion", measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, @@ -78,7 +77,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="ContactSensor", device_class=BinarySensorDeviceClass.DOOR, - name="Contact", # value is inverted on matter to what we expect measurement_to_ha=lambda x: not x, ), @@ -90,7 +88,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="OccupancySensor", device_class=BinarySensorDeviceClass.OCCUPANCY, - name="Occupancy", # The first bit = if occupied measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), @@ -102,7 +99,6 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, - name="Battery Status", measurement_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevelEnum.kOk, ), diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py new file mode 100644 index 00000000000..6da88533edc --- /dev/null +++ b/homeassistant/components/matter/climate.py @@ -0,0 +1,313 @@ +"""Matter climate platform.""" +from __future__ import annotations + +from enum import IntEnum +from typing import TYPE_CHECKING, Any + +from chip.clusters import Objects as clusters +from matter_server.client.models import device_types +from matter_server.common.helpers.util import create_attribute_path_from_attribute + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + ClimateEntity, + ClimateEntityDescription, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +if TYPE_CHECKING: + from matter_server.client import MatterClient + from matter_server.client.models.node import MatterEndpoint + + from .discovery import MatterEntityInfo + +TEMPERATURE_SCALING_FACTOR = 100 +HVAC_SYSTEM_MODE_MAP = { + HVACMode.OFF: 0, + HVACMode.HEAT_COOL: 1, + HVACMode.COOL: 3, + HVACMode.HEAT: 4, +} +SystemModeEnum = clusters.Thermostat.Enums.ThermostatSystemMode +ControlSequenceEnum = clusters.Thermostat.Enums.ThermostatControlSequence +ThermostatFeature = clusters.Thermostat.Bitmaps.ThermostatFeature + + +class ThermostatRunningState(IntEnum): + """Thermostat Running State, Matter spec Thermostat 7.33.""" + + Heat = 1 # 1 << 0 = 1 + Cool = 2 # 1 << 1 = 2 + Fan = 4 # 1 << 2 = 4 + HeatStage2 = 8 # 1 << 3 = 8 + CoolStage2 = 16 # 1 << 4 = 16 + FanStage2 = 32 # 1 << 5 = 32 + FanStage3 = 64 # 1 << 6 = 64 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter climate platform from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.CLIMATE, async_add_entities) + + +class MatterClimate(MatterEntity, ClimateEntity): + """Representation of a Matter climate entity.""" + + _attr_temperature_unit: str = UnitOfTemperature.CELSIUS + _attr_supported_features: ClimateEntityFeature = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + _attr_hvac_mode: HVACMode = HVACMode.OFF + + def __init__( + self, + matter_client: MatterClient, + endpoint: MatterEndpoint, + entity_info: MatterEntityInfo, + ) -> None: + """Initialize the Matter climate entity.""" + super().__init__(matter_client, endpoint, entity_info) + + # set hvac_modes based on feature map + self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF] + feature_map = int( + self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap) + ) + if feature_map & ThermostatFeature.kHeating: + self._attr_hvac_modes.append(HVACMode.HEAT) + if feature_map & ThermostatFeature.kCooling: + self._attr_hvac_modes.append(HVACMode.COOL) + if feature_map & ThermostatFeature.kAutoMode: + self._attr_hvac_modes.append(HVACMode.HEAT_COOL) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) + if target_hvac_mode is not None: + await self.async_set_hvac_mode(target_hvac_mode) + + current_mode = target_hvac_mode or self.hvac_mode + command = None + if current_mode in (HVACMode.HEAT, HVACMode.COOL): + # when current mode is either heat or cool, the temperature arg must be provided. + temperature: float | None = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + raise ValueError("Temperature must be provided") + if self.target_temperature is None: + raise ValueError("Current target_temperature should not be None") + command = self._create_optional_setpoint_command( + clusters.Thermostat.Enums.SetpointAdjustMode.kCool + if current_mode == HVACMode.COOL + else clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, + temperature, + self.target_temperature, + ) + elif current_mode == HVACMode.HEAT_COOL: + temperature_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW) + temperature_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if temperature_low is None or temperature_high is None: + raise ValueError( + "temperature_low and temperature_high must be provided" + ) + if ( + self.target_temperature_low is None + or self.target_temperature_high is None + ): + raise ValueError( + "current target_temperature_low and target_temperature_high should not be None" + ) + # due to ha send both high and low temperature, we need to check which one is changed + command = self._create_optional_setpoint_command( + clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, + temperature_low, + self.target_temperature_low, + ) + if command is None: + command = self._create_optional_setpoint_command( + clusters.Thermostat.Enums.SetpointAdjustMode.kCool, + temperature_high, + self.target_temperature_high, + ) + if command: + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + system_mode_path = create_attribute_path_from_attribute( + endpoint_id=self._endpoint.endpoint_id, + attribute=clusters.Thermostat.Attributes.SystemMode, + ) + system_mode_value = HVAC_SYSTEM_MODE_MAP.get(hvac_mode) + if system_mode_value is None: + raise ValueError(f"Unsupported hvac mode {hvac_mode} in Matter") + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=system_mode_path, + value=system_mode_value, + ) + # we need to optimistically update the attribute's value here + # to prevent a race condition when adjusting the mode and temperature + # in the same call + self._endpoint.set_attribute_value(system_mode_path, system_mode_value) + self._update_from_device() + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + self._attr_current_temperature = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.LocalTemperature + ) + # update hvac_mode from SystemMode + system_mode_value = int( + self.get_matter_attribute_value(clusters.Thermostat.Attributes.SystemMode) + ) + match system_mode_value: + case SystemModeEnum.kAuto: + self._attr_hvac_mode = HVACMode.HEAT_COOL + case SystemModeEnum.kDry: + self._attr_hvac_mode = HVACMode.DRY + case SystemModeEnum.kFanOnly: + self._attr_hvac_mode = HVACMode.FAN_ONLY + case SystemModeEnum.kCool | SystemModeEnum.kPrecooling: + self._attr_hvac_mode = HVACMode.COOL + case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat: + self._attr_hvac_mode = HVACMode.HEAT + case _: + self._attr_hvac_mode = HVACMode.OFF + # running state is an optional attribute + # which we map to hvac_action if it exists (its value is not None) + self._attr_hvac_action = None + if running_state_value := self.get_matter_attribute_value( + clusters.Thermostat.Attributes.ThermostatRunningState + ): + match running_state_value: + case ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2: + self._attr_hvac_action = HVACAction.HEATING + case ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2: + self._attr_hvac_action = HVACAction.COOLING + case ( + ThermostatRunningState.Fan + | ThermostatRunningState.FanStage2 + | ThermostatRunningState.FanStage3 + ): + self._attr_hvac_action = HVACAction.FAN + case _: + self._attr_hvac_action = HVACAction.OFF + # update target_temperature + if self._attr_hvac_mode == HVACMode.HEAT_COOL: + self._attr_target_temperature = None + elif self._attr_hvac_mode == HVACMode.COOL: + self._attr_target_temperature = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.OccupiedCoolingSetpoint + ) + else: + self._attr_target_temperature = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + ) + # update target temperature high/low + if self._attr_hvac_mode == HVACMode.HEAT_COOL: + self._attr_target_temperature_high = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.OccupiedCoolingSetpoint + ) + self._attr_target_temperature_low = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + ) + else: + self._attr_target_temperature_high = None + self._attr_target_temperature_low = None + # update min_temp + if self._attr_hvac_mode == HVACMode.COOL: + attribute = clusters.Thermostat.Attributes.AbsMinCoolSetpointLimit + else: + attribute = clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit + if (value := self._get_temperature_in_degrees(attribute)) is not None: + self._attr_min_temp = value + else: + self._attr_min_temp = DEFAULT_MIN_TEMP + # update max_temp + if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT_COOL): + attribute = clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit + else: + attribute = clusters.Thermostat.Attributes.AbsMaxCoolSetpointLimit + if (value := self._get_temperature_in_degrees(attribute)) is not None: + self._attr_max_temp = value + else: + self._attr_max_temp = DEFAULT_MAX_TEMP + + def _get_temperature_in_degrees( + self, attribute: type[clusters.ClusterAttributeDescriptor] + ) -> float | None: + """Return the scaled temperature value for the given attribute.""" + if value := self.get_matter_attribute_value(attribute): + return float(value) / TEMPERATURE_SCALING_FACTOR + return None + + @staticmethod + def _create_optional_setpoint_command( + mode: clusters.Thermostat.Enums.SetpointAdjustMode, + target_temp: float, + current_target_temp: float, + ) -> clusters.Thermostat.Commands.SetpointRaiseLower | None: + """Create a setpoint command if the target temperature is different from the current one.""" + + temp_diff = int((target_temp - current_target_temp) * 10) + + if temp_diff == 0: + return None + + return clusters.Thermostat.Commands.SetpointRaiseLower( + mode, + temp_diff, + ) + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.CLIMATE, + entity_description=ClimateEntityDescription( + key="MatterThermostat", + name=None, + ), + entity_class=MatterClimate, + required_attributes=(clusters.Thermostat.Attributes.LocalTemperature,), + optional_attributes=( + clusters.Thermostat.Attributes.FeatureMap, + clusters.Thermostat.Attributes.ControlSequenceOfOperation, + clusters.Thermostat.Attributes.Occupancy, + clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.Attributes.SystemMode, + clusters.Thermostat.Attributes.ThermostatRunningMode, + clusters.Thermostat.Attributes.ThermostatRunningState, + clusters.Thermostat.Attributes.TemperatureSetpointHold, + clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint, + clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint, + ), + device_type=(device_types.Thermostat,), + ), +] diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 61c5d4cd2ff..590f325cf22 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -199,7 +199,7 @@ class MatterCover(MatterEntity, CoverEntity): DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.COVER, - entity_description=CoverEntityDescription(key="MatterCover"), + entity_description=CoverEntityDescription(key="MatterCover", name=None), entity_class=MatterCover, required_attributes=( clusters.WindowCovering.Attributes.OperationalStatus, @@ -212,7 +212,9 @@ DISCOVERY_SCHEMAS = [ ), MatterDiscoverySchema( platform=Platform.COVER, - entity_description=CoverEntityDescription(key="MatterCoverPositionAwareLift"), + entity_description=CoverEntityDescription( + key="MatterCoverPositionAwareLift", name=None + ), entity_class=MatterCover, required_attributes=( clusters.WindowCovering.Attributes.OperationalStatus, @@ -225,7 +227,9 @@ DISCOVERY_SCHEMAS = [ ), MatterDiscoverySchema( platform=Platform.COVER, - entity_description=CoverEntityDescription(key="MatterCoverPositionAwareTilt"), + entity_description=CoverEntityDescription( + key="MatterCoverPositionAwareTilt", name=None + ), entity_class=MatterCover, required_attributes=( clusters.WindowCovering.Attributes.OperationalStatus, @@ -239,7 +243,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.COVER, entity_description=CoverEntityDescription( - key="MatterCoverPositionAwareLiftAndTilt" + key="MatterCoverPositionAwareLiftAndTilt", name=None ), entity_class=MatterCover, required_attributes=( diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 28f5b6b7f90..0b4bacf00ca 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -10,6 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS +from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS @@ -19,6 +20,7 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, + Platform.CLIMATE: CLIMATE_SENSOR_SCHEMAS, Platform.COVER: COVER_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index bf0a74ef845..0457cfaa810 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -75,20 +75,25 @@ class MatterEntity(Entity): await super().async_added_to_hass() # Subscribe to attribute updates. + sub_paths: list[str] = [] for attr_cls in self._entity_info.attributes_to_watch: attr_path = self.get_matter_attribute_path(attr_cls) self._attributes_map[attr_cls] = attr_path + sub_paths.append(attr_path) self._unsubscribes.append( - self.matter_client.subscribe( + self.matter_client.subscribe_events( callback=self._on_matter_event, event_filter=EventType.ATTRIBUTE_UPDATED, node_filter=self._endpoint.node.node_id, attr_path_filter=attr_path, ) ) + await self.matter_client.subscribe_attribute( + self._endpoint.node.node_id, sub_paths + ) # subscribe to node (availability changes) self._unsubscribes.append( - self.matter_client.subscribe( + self.matter_client.subscribe_events( callback=self._on_matter_event, event_filter=EventType.NODE_UPDATED, node_filter=self._endpoint.node.node_id, diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 4b609950256..0274c80edf8 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -95,7 +95,7 @@ async def get_node_from_device_entry( node = next( ( node - for node in await matter_client.get_nodes() + for node in matter_client.get_nodes() for endpoint in node.endpoints.values() if get_device_id(server_info, endpoint) == device_id ), diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index ae2b7a68c3a..facdb6752d3 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -220,7 +220,7 @@ class MatterLight(MatterEntity, LightEntity): return round( renormalize( level_control.currentLevel, - (level_control.minLevel, level_control.maxLevel), + (level_control.minLevel or 0, level_control.maxLevel or 254), (0, 255), ) ) @@ -299,6 +299,8 @@ class MatterLight(MatterEntity, LightEntity): # colormode(s) if self._entity_info.endpoint.has_attribute( None, clusters.ColorControl.Attributes.ColorMode + ) and self._entity_info.endpoint.has_attribute( + None, clusters.ColorControl.Attributes.ColorCapabilities ): capabilities = self.get_matter_attribute_value( clusters.ColorControl.Attributes.ColorCapabilities @@ -356,7 +358,7 @@ class MatterLight(MatterEntity, LightEntity): DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LIGHT, - entity_description=LightEntityDescription(key="MatterLight"), + entity_description=LightEntityDescription(key="MatterLight", name=None), entity_class=MatterLight, required_attributes=(clusters.OnOff.Attributes.OnOff,), optional_attributes=( @@ -375,4 +377,90 @@ DISCOVERY_SCHEMAS = [ device_types.OnOffLight, ), ), + # Additional schema to match (HS Color) lights with incorrect/missing device type + MatterDiscoverySchema( + platform=Platform.LIGHT, + entity_description=LightEntityDescription( + key="MatterHSColorLightFallback", name=None + ), + entity_class=MatterLight, + required_attributes=( + clusters.OnOff.Attributes.OnOff, + clusters.LevelControl.Attributes.CurrentLevel, + clusters.ColorControl.Attributes.CurrentHue, + clusters.ColorControl.Attributes.CurrentSaturation, + ), + optional_attributes=( + clusters.ColorControl.Attributes.ColorTemperatureMireds, + clusters.ColorControl.Attributes.ColorMode, + clusters.ColorControl.Attributes.CurrentX, + clusters.ColorControl.Attributes.CurrentY, + ), + ), + # Additional schema to match (XY Color) lights with incorrect/missing device type + MatterDiscoverySchema( + platform=Platform.LIGHT, + entity_description=LightEntityDescription( + key="MatterXYColorLightFallback", name=None + ), + entity_class=MatterLight, + required_attributes=( + clusters.OnOff.Attributes.OnOff, + clusters.LevelControl.Attributes.CurrentLevel, + clusters.ColorControl.Attributes.CurrentX, + clusters.ColorControl.Attributes.CurrentY, + ), + optional_attributes=( + clusters.ColorControl.Attributes.ColorTemperatureMireds, + clusters.ColorControl.Attributes.ColorMode, + clusters.ColorControl.Attributes.CurrentHue, + clusters.ColorControl.Attributes.CurrentSaturation, + ), + ), + # Additional schema to match (color temperature) lights with incorrect/missing device type + MatterDiscoverySchema( + platform=Platform.LIGHT, + entity_description=LightEntityDescription( + key="MatterColorTemperatureLightFallback", name=None + ), + entity_class=MatterLight, + required_attributes=( + clusters.OnOff.Attributes.OnOff, + clusters.LevelControl.Attributes.CurrentLevel, + clusters.ColorControl.Attributes.ColorTemperatureMireds, + ), + optional_attributes=(clusters.ColorControl.Attributes.ColorMode,), + ), + # Additional schema to match generic dimmable lights with incorrect/missing device type + MatterDiscoverySchema( + platform=Platform.LIGHT, + entity_description=LightEntityDescription( + key="MatterDimmableLightFallback", name=None + ), + entity_class=MatterLight, + required_attributes=( + clusters.OnOff.Attributes.OnOff, + clusters.LevelControl.Attributes.CurrentLevel, + ), + optional_attributes=( + clusters.ColorControl.Attributes.ColorMode, + clusters.ColorControl.Attributes.CurrentHue, + clusters.ColorControl.Attributes.CurrentSaturation, + clusters.ColorControl.Attributes.CurrentX, + clusters.ColorControl.Attributes.CurrentY, + clusters.ColorControl.Attributes.ColorTemperatureMireds, + ), + # important: make sure to rule out all device types that are also based on the + # onoff and levelcontrol clusters ! + not_device_type=( + device_types.Fan, + device_types.GenericSwitch, + device_types.OnOffPlugInUnit, + device_types.HeatingCoolingUnit, + device_types.Pump, + device_types.CastingVideoClient, + device_types.VideoRemoteControl, + device_types.Speaker, + ), + ), ] diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index c529ee12c5f..a5f625f9e73 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -8,7 +8,7 @@ from chip.clusters import Objects as clusters from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_CODE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,6 +33,26 @@ class MatterLock(MatterEntity, LockEntity): features: int | None = None + @property + def code_format(self) -> str | None: + """Regex for code format or None if no code is required.""" + if self.get_matter_attribute_value( + clusters.DoorLock.Attributes.RequirePINforRemoteOperation + ): + min_pincode_length = int( + self.get_matter_attribute_value( + clusters.DoorLock.Attributes.MinPINCodeLength + ) + ) + max_pincode_length = int( + self.get_matter_attribute_value( + clusters.DoorLock.Attributes.MaxPINCodeLength + ) + ) + return f"^\\d{{{min_pincode_length},{max_pincode_length}}}$" + + return None + @property def supports_door_position_sensor(self) -> bool: """Return True if the lock supports door position sensor.""" @@ -56,11 +76,25 @@ class MatterLock(MatterEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock with pin if needed.""" - await self.send_device_command(command=clusters.DoorLock.Commands.LockDoor()) + code: str = kwargs.get( + ATTR_CODE, + self._lock_option_default_code, + ) + code_bytes = code.encode() if code else None + await self.send_device_command( + command=clusters.DoorLock.Commands.LockDoor(code_bytes) + ) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock with pin if needed.""" - await self.send_device_command(command=clusters.DoorLock.Commands.UnlockDoor()) + code: str = kwargs.get( + ATTR_CODE, + self._lock_option_default_code, + ) + code_bytes = code.encode() if code else None + await self.send_device_command( + command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) + ) @callback def _update_from_device(self) -> None: @@ -116,24 +150,24 @@ class DoorLockFeature(IntFlag): Should be replaced by the library provided one once that is released. """ - kPinCredential = 0x1 - kRfidCredential = 0x2 - kFingerCredentials = 0x4 - kLogging = 0x8 - kWeekDayAccessSchedules = 0x10 - kDoorPositionSensor = 0x20 - kFaceCredentials = 0x40 - kCredentialsOverTheAirAccess = 0x80 - kUser = 0x100 - kNotification = 0x200 - kYearDayAccessSchedules = 0x400 - kHolidaySchedules = 0x800 + kPinCredential = 0x1 # noqa: N815 + kRfidCredential = 0x2 # noqa: N815 + kFingerCredentials = 0x4 # noqa: N815 + kLogging = 0x8 # noqa: N815 + kWeekDayAccessSchedules = 0x10 # noqa: N815 + kDoorPositionSensor = 0x20 # noqa: N815 + kFaceCredentials = 0x40 # noqa: N815 + kCredentialsOverTheAirAccess = 0x80 # noqa: N815 + kUser = 0x100 # noqa: N815 + kNotification = 0x200 # noqa: N815 + kYearDayAccessSchedules = 0x400 # noqa: N815 + kHolidaySchedules = 0x800 # noqa: N815 DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LOCK, - entity_description=LockEntityDescription(key="MatterLock"), + entity_description=LockEntityDescription(key="MatterLock", name=None), entity_class=MatterLock, required_attributes=(clusters.DoorLock.Attributes.LockState,), optional_attributes=(clusters.DoorLock.Attributes.DoorState,), diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 707f7e70ee3..85434407a10 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.5.1"] + "requirements": ["python-matter-server==3.6.3"] } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 84e68695d63..027dcda65a7 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -68,7 +68,6 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="TemperatureSensor", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, measurement_to_ha=lambda x: x / 100, @@ -80,7 +79,6 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="PressureSensor", - name="Pressure", native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, measurement_to_ha=lambda x: x / 10, @@ -92,9 +90,8 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="FlowSensor", - name="Flow", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - device_class=SensorDeviceClass.WATER, # what is the device class here ? + translation_key="flow", measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, @@ -104,7 +101,6 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="HumiditySensor", - name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, measurement_to_ha=lambda x: x / 100, @@ -118,7 +114,6 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="LightSensor", - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), @@ -130,7 +125,6 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="PowerSource", - name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, # value has double precision diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 594998c236f..dc5eb30df51 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -43,5 +43,12 @@ "install_addon": "Please wait while the Matter Server add-on installation finishes. This can take several minutes.", "start_addon": "Please wait while the Matter Server add-on starts. This add-on is what powers Matter in Home Assistant. This may take some seconds." } + }, + "entity": { + "sensor": { + "flow": { + "name": "Flow" + } + } } } diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 2eb3c22c1f7..e1fb4464b83 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -63,7 +63,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SWITCH, entity_description=SwitchEntityDescription( - key="MatterPlug", device_class=SwitchDeviceClass.OUTLET + key="MatterPlug", device_class=SwitchDeviceClass.OUTLET, name=None ), entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), @@ -77,6 +77,7 @@ DISCOVERY_SCHEMAS = [ device_types.ColorDimmerSwitch, device_types.DimmerSwitch, device_types.OnOffLightSwitch, + device_types.Thermostat, ), ), ] diff --git a/homeassistant/components/mazda/button.py b/homeassistant/components/mazda/button.py index 99a1a4ac2ff..1b1e51db035 100644 --- a/homeassistant/components/mazda/button.py +++ b/homeassistant/components/mazda/button.py @@ -78,21 +78,25 @@ BUTTON_ENTITIES = [ key="start_engine", name="Start engine", icon="mdi:engine", + is_supported=lambda data: not data["isElectric"], ), MazdaButtonEntityDescription( key="stop_engine", name="Stop engine", icon="mdi:engine-off", + is_supported=lambda data: not data["isElectric"], ), MazdaButtonEntityDescription( key="turn_on_hazard_lights", name="Turn on hazard lights", icon="mdi:hazard-lights", + is_supported=lambda data: not data["isElectric"], ), MazdaButtonEntityDescription( key="turn_off_hazard_lights", name="Turn off hazard lights", icon="mdi:hazard-lights", + is_supported=lambda data: not data["isElectric"], ), MazdaButtonEntityDescription( key="refresh_vehicle_status", diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json index 2c2aafa960e..01f77cb2d38 100644 --- a/homeassistant/components/mazda/manifest.json +++ b/homeassistant/components/mazda/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pymazda"], "quality_scale": "platinum", - "requirements": ["pymazda==0.3.8"] + "requirements": ["pymazda==0.3.9"] } diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index a0c542d72a5..a35650f0092 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -2,8 +2,8 @@ import logging import voluptuous as vol -from youtube_dl import YoutubeDL -from youtube_dl.utils import DownloadError, ExtractorError +from yt_dlp import YoutubeDL +from yt_dlp.utils import DownloadError, ExtractorError from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, @@ -127,7 +127,7 @@ class MediaExtractor: _LOGGER.error("Could not extract stream for the query: %s", query) raise MEQueryException() from err - return requested_stream["url"] + return requested_stream["webpage_url"] return stream_selector @@ -147,7 +147,7 @@ class MediaExtractor: if entity_id: data[ATTR_ENTITY_ID] = entity_id - self.hass.async_create_task( + self.hass.create_task( self.hass.services.async_call(MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, data) ) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index c358b29062a..ccab196032f 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -5,7 +5,7 @@ "dependencies": ["media_player"], "documentation": "https://www.home-assistant.io/integrations/media_extractor", "iot_class": "calculated", - "loggers": ["youtube_dl"], + "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["youtube_dl==2021.12.17"] + "requirements": ["yt-dlp==2023.3.4"] } diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index 2b046868f16..1e9be742c53 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -42,9 +42,8 @@ def async_process_play_media_url( if parsed.is_absolute(): if not is_hass_url(hass, media_content_id): return media_content_id - else: - if media_content_id[0] != "/": - return media_content_id + elif media_content_id[0] != "/": + return media_content_id if parsed.query: logging.getLogger(__name__).debug( diff --git a/homeassistant/components/media_player/device_condition.py b/homeassistant/components/media_player/device_condition.py index 9e3981ed983..5efee0c0b49 100644 --- a/homeassistant/components/media_player/device_condition.py +++ b/homeassistant/components/media_player/device_condition.py @@ -39,7 +39,7 @@ CONDITION_TYPES = { CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), } ) @@ -62,7 +62,7 @@ async def async_get_conditions( CONF_CONDITION: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, } conditions += [{**base_condition, CONF_TYPE: cond} for cond in CONDITION_TYPES] @@ -88,8 +88,11 @@ def async_condition_from_config( else: # is_playing state = STATE_PLAYING + registry = er.async_get(hass) + entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + return condition.state(hass, entity_id, state) return test_is_state diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index 58fc0aca84f..e626059841c 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -33,7 +33,7 @@ TRIGGER_TYPES = {"turned_on", "turned_off", "buffering", "idle", "paused", "play MEDIA_PLAYER_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_FOR): cv.positive_time_period_dict, } @@ -66,7 +66,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: trigger, } for trigger in TRIGGER_TYPES diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index f3c5c92eaa6..62cf7815613 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -193,7 +193,7 @@ async def websocket_resolve_media( ) -> None: """Resolve media.""" try: - media = await async_resolve_media(hass, msg["media_content_id"]) + media = await async_resolve_media(hass, msg["media_content_id"], None) except Unresolvable as err: connection.send_error(msg["id"], "resolve_media_failed", str(err)) return diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index a2854479abd..e5f70bc25a0 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -45,6 +45,7 @@ ZONE_ENTITY_DESCRIPTIONS = [ device_class=SwitchDeviceClass.SWITCH, icon="mdi:sprinkler", key="manual", + name=None, on_off_fn=lambda valve, bool: valve.set_is_watering(bool), state_fn=lambda valve: valve.is_watering, ), diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index 5b2a756847e..dcc493570ba 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -10,19 +10,24 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY, ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, ) @@ -180,6 +185,9 @@ FORECAST_MAP = { ATTR_FORECAST_TIME: "datetime", ATTR_FORECAST_WIND_BEARING: "wind_bearing", ATTR_FORECAST_NATIVE_WIND_SPEED: "wind_speed", + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: "wind_gust", + ATTR_FORECAST_CLOUD_COVERAGE: "cloudiness", + ATTR_FORECAST_HUMIDITY: "humidity", } ATTR_MAP = { @@ -189,4 +197,6 @@ ATTR_MAP = { ATTR_WEATHER_VISIBILITY: "visibility", ATTR_WEATHER_WIND_BEARING: "wind_bearing", ATTR_WEATHER_WIND_SPEED: "wind_speed", + ATTR_WEATHER_WIND_GUST_SPEED: "wind_gust", + ATTR_WEATHER_CLOUD_COVERAGE: "cloudiness", } diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index 32d37e9b4ff..5c476b10665 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["pyMetno==0.10.0"] + "requirements": ["PyMetno==0.10.0"] } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index a6dcb23cc47..05642c12991 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -7,10 +7,12 @@ from typing import Any from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TIME, + ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, Forecast, WeatherEntity, @@ -174,6 +176,20 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ATTR_MAP[ATTR_WEATHER_WIND_BEARING] ) + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed in native units.""" + return self.coordinator.data.current_weather_data.get( + ATTR_MAP[ATTR_WEATHER_WIND_GUST_SPEED] + ) + + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage.""" + return self.coordinator.data.current_weather_data.get( + ATTR_MAP[ATTR_WEATHER_CLOUD_COVERAGE] + ) + @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" diff --git a/homeassistant/components/met_eireann/manifest.json b/homeassistant/components/met_eireann/manifest.json index 1e05787158a..72afc6977dd 100644 --- a/homeassistant/components/met_eireann/manifest.json +++ b/homeassistant/components/met_eireann/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met_eireann", "iot_class": "cloud_polling", "loggers": ["meteireann"], - "requirements": ["pyMetEireann==2021.8.0"] + "requirements": ["PyMetEireann==2021.8.0"] } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index c87aea05260..8c27f2970a3 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -247,11 +247,7 @@ class MeteoFranceSensor(CoordinatorEntity[DataUpdateCoordinator[_DataT]], Sensor @property def device_info(self) -> DeviceInfo: """Return the device info.""" - assert ( - self.platform - and self.platform.config_entry - and self.platform.config_entry.unique_id - ) + assert self.platform.config_entry and self.platform.config_entry.unique_id return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, @@ -275,11 +271,10 @@ class MeteoFranceSensor(CoordinatorEntity[DataUpdateCoordinator[_DataT]], Sensor value = data[0][path[1]] # General case + elif len(path) == 3: + value = data[path[1]][path[2]] else: - if len(path) == 3: - value = data[path[1]][path[2]] - else: - value = data[path[1]] + value = data[path[1]] if self.entity_description.key in ("wind_speed", "wind_gust"): # convert API wind speed from m/s to km/h diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index e1a530eef97..7709ba0a638 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -109,11 +109,7 @@ class MeteoFranceWeather( @property def device_info(self) -> DeviceInfo: """Return the device info.""" - assert ( - self.platform - and self.platform.config_entry - and self.platform.config_entry.unique_id - ) + assert self.platform.config_entry and self.platform.config_entry.unique_id return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 82a9accac59..f57a9146858 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -73,12 +74,16 @@ SCHEMA_TRAIN_SERVICE = vol.Schema({vol.Required(ATTR_GROUP): cv.slugify}) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Microsoft Face.""" + component = EntityComponent[MicrosoftFaceGroupEntity]( + logging.getLogger(__name__), DOMAIN, hass + ) entities: dict[str, MicrosoftFaceGroupEntity] = {} face = MicrosoftFace( hass, config[DOMAIN].get(CONF_AZURE_REGION), config[DOMAIN].get(CONF_API_KEY), config[DOMAIN].get(CONF_TIMEOUT), + component, entities, ) @@ -99,9 +104,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: await face.call_api("put", f"persongroups/{g_id}", {"name": name}) face.store[g_id] = {} + old_entity = entities.pop(g_id, None) + if old_entity: + await component.async_remove_entity(old_entity.entity_id) entities[g_id] = MicrosoftFaceGroupEntity(hass, face, g_id, name) - entities[g_id].async_write_ha_state() + await component.async_add_entities([entities[g_id]]) except HomeAssistantError as err: _LOGGER.error("Can't create group '%s' with error: %s", g_id, err) @@ -118,7 +126,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: face.store.pop(g_id) entity = entities.pop(g_id) - hass.states.async_remove(entity.entity_id, service.context) + await component.async_remove_entity(entity.entity_id) except HomeAssistantError as err: _LOGGER.error("Can't delete group '%s' with error: %s", g_id, err) @@ -244,7 +252,7 @@ class MicrosoftFaceGroupEntity(Entity): class MicrosoftFace: """Microsoft Face api for Home Assistant.""" - def __init__(self, hass, server_loc, api_key, timeout, entities): + def __init__(self, hass, server_loc, api_key, timeout, component, entities): """Initialize Microsoft Face api.""" self.hass = hass self.websession = async_get_clientsession(hass) @@ -252,6 +260,7 @@ class MicrosoftFace: self._api_key = api_key self._server_url = f"https://{server_loc}.{FACE_API_URL}" self._store = {} + self._component: EntityComponent[MicrosoftFaceGroupEntity] = component self._entities = entities @property @@ -263,25 +272,30 @@ class MicrosoftFace: """Load all group/person data into local store.""" groups = await self.call_api("get", "persongroups") - tasks = [] + remove_tasks = [] + new_entities = [] for group in groups: g_id = group["personGroupId"] self._store[g_id] = {} + old_entity = self._entities.pop(g_id, None) + if old_entity: + remove_tasks.append( + self._component.async_remove_entity(old_entity.entity_id) + ) + self._entities[g_id] = MicrosoftFaceGroupEntity( self.hass, self, g_id, group["name"] ) + new_entities.append(self._entities[g_id]) persons = await self.call_api("get", f"persongroups/{g_id}/persons") for person in persons: self._store[g_id][person["name"]] = person["personId"] - tasks.append( - asyncio.create_task(self._entities[g_id].async_update_ha_state()) - ) - - if tasks: - await asyncio.wait(tasks) + if remove_tasks: + await asyncio.gather(remove_tasks) + await self._component.async_add_entities(new_entities) async def call_api(self, method, function, data=None, binary=False, params=None): """Make an api call.""" diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 42b759b3cdf..f1487ed59f1 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -91,8 +91,10 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): """Representation of a Mill Thermostat device.""" _attr_fan_modes = [FAN_ON, FAN_OFF] + _attr_has_entity_name = True _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP + _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__( @@ -106,12 +108,11 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): self._id = heater.device_id self._attr_unique_id = heater.device_id - self._attr_name = heater.name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, heater.device_id)}, manufacturer=MANUFACTURER, model=f"Generation {heater.generation}", - name=self.name, + name=heater.name, ) if heater.is_gen1: self._attr_hvac_modes = [HVACMode.HEAT] @@ -202,10 +203,12 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): """Representation of a Mill Thermostat device.""" + _attr_has_entity_name = True _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP + _attr_name = None _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -213,7 +216,6 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit def __init__(self, coordinator: MillDataUpdateCoordinator) -> None: """Initialize the thermostat.""" super().__init__(coordinator) - self._attr_name = coordinator.mill_data_connection.name if mac := coordinator.mill_data_connection.mac_address: self._attr_unique_id = mac self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/mjpeg/__init__.py b/homeassistant/components/mjpeg/__init__.py index 27131d9d18f..a5bfc49edf6 100644 --- a/homeassistant/components/mjpeg/__init__.py +++ b/homeassistant/components/mjpeg/__init__.py @@ -2,10 +2,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .camera import MjpegCamera -from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, PLATFORMS +from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, PLATFORMS from .util import filter_urllib3_logging __all__ = [ @@ -15,6 +16,8 @@ __all__ = [ "filter_urllib3_logging", ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MJPEG IP Camera integration.""" diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 70c23da66e2..3d33af38761 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -10,7 +10,11 @@ from homeassistant.components.webhook import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, discovery +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery, +) from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -36,6 +40,8 @@ from .webhook import handle_webhook PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the mobile app component.""" diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 4f416874f9d..43f43585775 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -115,7 +115,10 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): self._result = result.bits else: self._result = result.registers - self._attr_is_on = bool(self._result[0] & 1) + if len(self._result) >= 1: + self._attr_is_on = bool(self._result[0] & 1) + else: + self._attr_available = False self.async_write_ha_state() if self._coordinator: diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index bb64a264248..c2e6b9ef467 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.1.3"] + "requirements": ["pymodbus==3.3.1"] } diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 537fe81da11..fac20073fe9 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -32,9 +32,7 @@ class PhoneModemFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: """Handle USB Discovery.""" - device = discovery_info.device - - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + dev_path = discovery_info.device unique_id = f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" if ( await self.validate_device_errors(dev_path=dev_path, unique_id=unique_id) diff --git a/homeassistant/components/modem_callerid/manifest.json b/homeassistant/components/modem_callerid/manifest.json index 1ff348fb3b7..34e5be43155 100644 --- a/homeassistant/components/modem_callerid/manifest.json +++ b/homeassistant/components/modem_callerid/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["phone_modem"], - "requirements": ["phone_modem==0.1.1"], + "requirements": ["phone-modem==0.1.1"], "usb": [ { "vid": "0572", diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index f8e1cd24abe..10251fc679d 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -50,14 +50,14 @@ class MoonSensorEntity(SensorEntity): _attr_name = "Phase" _attr_device_class = SensorDeviceClass.ENUM _attr_options = [ - STATE_FIRST_QUARTER, - STATE_FULL_MOON, - STATE_LAST_QUARTER, STATE_NEW_MOON, - STATE_WANING_CRESCENT, - STATE_WANING_GIBBOUS, STATE_WAXING_CRESCENT, + STATE_FIRST_QUARTER, STATE_WAXING_GIBBOUS, + STATE_FULL_MOON, + STATE_WANING_GIBBOUS, + STATE_LAST_QUARTER, + STATE_WANING_CRESCENT, ] _attr_translation_key = "phase" diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index 71104192153..d6b5618bf97 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/mopeka", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mopeka_iot_ble==0.4.1"] + "requirements": ["mopeka-iot-ble==0.4.1"] } diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 332a30a5e5f..d241f03a02e 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -28,4 +28,6 @@ SERVICE_SET_ABSOLUTE_POSITION = "set_absolute_position" UPDATE_INTERVAL = 600 UPDATE_INTERVAL_FAST = 60 +UPDATE_DELAY_STOP = 3 UPDATE_INTERVAL_MOVING = 5 +UPDATE_INTERVAL_MOVING_WIFI = 45 diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index aaf74a96de0..17918133614 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -36,7 +36,9 @@ from .const import ( KEY_VERSION, MANUFACTURER, SERVICE_SET_ABSOLUTE_POSITION, + UPDATE_DELAY_STOP, UPDATE_INTERVAL_MOVING, + UPDATE_INTERVAL_MOVING_WIFI, ) from .gateway import device_name @@ -191,13 +193,15 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): self._blind = blind self._api_lock = coordinator.api_lock - self._requesting_position = False + self._requesting_position: CALLBACK_TYPE | None = None self._previous_positions = [] if blind.device_type in DEVICE_TYPES_WIFI: + self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI via_device = () connections = {(dr.CONNECTION_NETWORK_MAC, blind.mac)} else: + self._update_interval_moving = UPDATE_INTERVAL_MOVING via_device = (DOMAIN, blind._gateway.mac) connections = {} sw_version = None @@ -271,23 +275,29 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): self.current_cover_position == prev_position for prev_position in self._previous_positions ): - # keep updating the position @UPDATE_INTERVAL_MOVING until the position does not change. - async_call_later( - self.hass, UPDATE_INTERVAL_MOVING, self.async_scheduled_update_request + # keep updating the position @self._update_interval_moving until the position does not change. + self._requesting_position = async_call_later( + self.hass, + self._update_interval_moving, + self.async_scheduled_update_request, ) else: self._previous_positions = [] - self._requesting_position = False + self._requesting_position = None + + async def async_request_position_till_stop(self, delay=None): + """Request the position of the blind every self._update_interval_moving seconds until it stops moving.""" + if delay is None: + delay = self._update_interval_moving - async def async_request_position_till_stop(self): - """Request the position of the blind every UPDATE_INTERVAL_MOVING seconds until it stops moving.""" self._previous_positions = [] - if self._requesting_position or self.current_cover_position is None: + if self.current_cover_position is None: return + if self._requesting_position is not None: + self._requesting_position() - self._requesting_position = True - async_call_later( - self.hass, UPDATE_INTERVAL_MOVING, self.async_scheduled_update_request + self._requesting_position = async_call_later( + self.hass, delay, self.async_scheduled_update_request ) async def async_open_cover(self, **kwargs: Any) -> None: @@ -334,6 +344,8 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop) + await self.async_request_position_till_stop(delay=UPDATE_DELAY_STOP) + class MotionTiltDevice(MotionPositionDevice): """Representation of a Motion Blind Device.""" @@ -378,6 +390,8 @@ class MotionTiltDevice(MotionPositionDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop) + await self.async_request_position_till_stop(delay=UPDATE_DELAY_STOP) + class MotionTiltOnlyDevice(MotionTiltDevice): """Representation of a Motion Blind Device.""" @@ -507,3 +521,5 @@ class MotionTDBUDevice(MotionPositionDevice): """Stop the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop, self._motor_key) + + await self.async_request_position_till_stop(delay=UPDATE_DELAY_STOP) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index de593385c1f..a5360090bb9 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -12,9 +12,6 @@ ABBREVIATIONS = { "avty_mode": "availability_mode", "avty_t": "availability_topic", "avty_tpl": "availability_template", - "away_mode_cmd_t": "away_mode_command_topic", - "away_mode_stat_tpl": "away_mode_state_template", - "away_mode_stat_t": "away_mode_state_topic", "b_tpl": "blue_template", "bri_cmd_tpl": "brightness_command_template", "bri_cmd_t": "brightness_command_topic", @@ -44,6 +41,7 @@ ABBREVIATIONS = { "cod_dis_req": "code_disarm_required", "cod_form": "code_format", "cod_trig_req": "code_trigger_required", + "cont_type": "content_type", "curr_hum_t": "current_humidity_topic", "curr_hum_tpl": "current_humidity_template", "curr_temp_t": "current_temperature_topic", @@ -80,16 +78,13 @@ ABBREVIATIONS = { "fan_mode_stat_t": "fan_mode_state_topic", "frc_upd": "force_update", "g_tpl": "green_template", - "hold_cmd_tpl": "hold_command_template", - "hold_cmd_t": "hold_command_topic", - "hold_stat_tpl": "hold_state_template", - "hold_stat_t": "hold_state_topic", "hs_cmd_t": "hs_command_topic", "hs_cmd_tpl": "hs_command_template", "hs_stat_t": "hs_state_topic", "hs_val_tpl": "hs_value_template", "ic": "icon", "img_e": "image_encoding", + "img_t": "image_topic", "init": "initial", "hum_cmd_t": "target_humidity_command_topic", "hum_cmd_tpl": "target_humidity_command_template", @@ -166,6 +161,7 @@ ABBREVIATIONS = { "pos_clsd": "position_closed", "pos_open": "position_open", "pow_cmd_t": "power_command_topic", + "pow_cmd_tpl": "power_command_template", "pow_stat_t": "power_state_topic", "pow_stat_tpl": "power_state_template", "pr_mode_cmd_t": "preset_mode_command_topic", @@ -243,7 +239,6 @@ ABBREVIATIONS = { "tilt_clsd_val": "tilt_closed_value", "tilt_cmd_t": "tilt_command_topic", "tilt_cmd_tpl": "tilt_command_template", - "tilt_inv_stat": "tilt_invert_state", "tilt_max": "tilt_max", "tilt_min": "tilt_min", "tilt_opnd_val": "tilt_opened_value", @@ -254,13 +249,11 @@ ABBREVIATIONS = { "t": "topic", "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", + "url_t": "url_topic", + "url_tpl": "url_template", "val_tpl": "value_template", "whit_cmd_t": "white_command_topic", "whit_scl": "white_scale", - "whit_val_cmd_t": "white_value_command_topic", - "whit_val_scl": "white_value_scale", - "whit_val_stat_t": "white_value_state_topic", - "whit_val_tpl": "white_value_template", "xy_cmd_t": "xy_command_topic", "xy_cmd_tpl": "xy_command_template", "xy_stat_t": "xy_state_topic", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index f580df9eab1..40ec754aa44 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -15,9 +15,7 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_HUMIDITY, - DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, - DEFAULT_MIN_TEMP, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -42,19 +40,40 @@ from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, + UnitOfTemperature, ) 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.util.unit_conversion import TemperatureConverter from . import subscription from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( + CONF_ACTION_TEMPLATE, + CONF_ACTION_TOPIC, + CONF_CURRENT_HUMIDITY_TEMPLATE, + CONF_CURRENT_HUMIDITY_TOPIC, + CONF_CURRENT_TEMP_TEMPLATE, + CONF_CURRENT_TEMP_TOPIC, CONF_ENCODING, + CONF_MODE_COMMAND_TEMPLATE, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_LIST, + CONF_MODE_STATE_TEMPLATE, + CONF_MODE_STATE_TOPIC, + CONF_PRECISION, CONF_QOS, CONF_RETAIN, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_INITIAL, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + CONF_TEMP_STATE_TEMPLATE, + CONF_TEMP_STATE_TOPIC, DEFAULT_OPTIMISTIC, PAYLOAD_NONE, ) @@ -73,33 +92,15 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "MQTT HVAC" -CONF_ACTION_TEMPLATE = "action_template" -CONF_ACTION_TOPIC = "action_topic" CONF_AUX_COMMAND_TOPIC = "aux_command_topic" CONF_AUX_STATE_TEMPLATE = "aux_state_template" CONF_AUX_STATE_TOPIC = "aux_state_topic" -# AWAY and HOLD mode topics and templates are no longer supported, -# support was removed with release 2022.9 -CONF_AWAY_MODE_COMMAND_TOPIC = "away_mode_command_topic" -CONF_AWAY_MODE_STATE_TEMPLATE = "away_mode_state_template" -CONF_AWAY_MODE_STATE_TOPIC = "away_mode_state_topic" -CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" -CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" -CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" -CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" CONF_FAN_MODE_LIST = "fan_modes" CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template" CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic" -# AWAY and HOLD mode topics and templates are no longer supported, -# support was removed with release 2022.9 -CONF_HOLD_COMMAND_TEMPLATE = "hold_command_template" -CONF_HOLD_COMMAND_TOPIC = "hold_command_topic" -CONF_HOLD_STATE_TEMPLATE = "hold_state_template" -CONF_HOLD_STATE_TOPIC = "hold_state_topic" -CONF_HOLD_LIST = "hold_modes" CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template" CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic" @@ -107,34 +108,26 @@ CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template" CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" CONF_HUMIDITY_MAX = "max_humidity" CONF_HUMIDITY_MIN = "min_humidity" -CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" -CONF_MODE_COMMAND_TOPIC = "mode_command_topic" -CONF_MODE_LIST = "modes" -CONF_MODE_STATE_TEMPLATE = "mode_state_template" -CONF_MODE_STATE_TOPIC = "mode_state_topic" -# CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE +# CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # was already removed or never added support was deprecated with release 2023.2 # and will be removed with release 2023.8 -CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_STATE_TEMPLATE = "power_state_template" CONF_POWER_STATE_TOPIC = "power_state_topic" -CONF_PRECISION = "precision" + +CONF_POWER_COMMAND_TOPIC = "power_command_topic" +CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" CONF_PRESET_MODES_LIST = "preset_modes" -# Support CONF_SEND_IF_OFF is removed with release 2022.9 -CONF_SEND_IF_OFF = "send_if_off" CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" CONF_SWING_MODE_LIST = "swing_modes" CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" -CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" -CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" @@ -143,13 +136,10 @@ CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template" CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic" CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template" CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic" -CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" -CONF_TEMP_STATE_TOPIC = "temperature_state_topic" -CONF_TEMP_INITIAL = "initial" -CONF_TEMP_MAX = "max_temp" -CONF_TEMP_MIN = "min_temp" CONF_TEMP_STEP = "temp_step" +DEFAULT_INITIAL_TEMPERATURE = 21.0 + MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( { climate.ATTR_AUX_HEAT, @@ -195,6 +185,7 @@ COMMAND_TEMPLATE_KEYS = { CONF_FAN_MODE_COMMAND_TEMPLATE, CONF_HUMIDITY_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TEMPLATE, CONF_SWING_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, @@ -312,6 +303,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_POWER_STATE_TEMPLATE): cv.template, vol.Optional(CONF_POWER_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRECISION): vol.In( @@ -338,9 +330,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ): cv.ensure_list, vol.Optional(CONF_SWING_MODE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_STATE_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_TEMP_INITIAL, default=21): cv.positive_int, - vol.Optional(CONF_TEMP_MIN, default=DEFAULT_MIN_TEMP): vol.Coerce(float), - vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_TEMP_INITIAL): cv.positive_int, + vol.Optional(CONF_TEMP_MIN): vol.Coerce(float), + vol.Optional(CONF_TEMP_MAX): vol.Coerce(float), vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float), vol.Optional(CONF_TEMP_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_TEMP_COMMAND_TOPIC): valid_publish_topic, @@ -360,23 +352,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( - # Support CONF_SEND_IF_OFF is removed with release 2022.9 - cv.removed(CONF_SEND_IF_OFF), - # AWAY and HOLD mode topics and templates are no longer supported, - # support was removed with release 2022.9 - cv.removed(CONF_AWAY_MODE_COMMAND_TOPIC), - cv.removed(CONF_AWAY_MODE_STATE_TEMPLATE), - cv.removed(CONF_AWAY_MODE_STATE_TOPIC), - cv.removed(CONF_HOLD_COMMAND_TEMPLATE), - cv.removed(CONF_HOLD_COMMAND_TOPIC), - cv.removed(CONF_HOLD_STATE_TEMPLATE), - cv.removed(CONF_HOLD_STATE_TOPIC), - cv.removed(CONF_HOLD_LIST), - # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE + # CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE # was already removed or never added support was deprecated with release 2023.2 # and will be removed with release 2023.8 - cv.deprecated(CONF_POWER_COMMAND_TOPIC), cv.deprecated(CONF_POWER_STATE_TEMPLATE), cv.deprecated(CONF_POWER_STATE_TOPIC), _PLATFORM_SCHEMA_BASE, @@ -389,22 +368,9 @@ _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA DISCOVERY_SCHEMA = vol.All( _DISCOVERY_SCHEMA_BASE, - # Support CONF_SEND_IF_OFF is removed with release 2022.9 - cv.removed(CONF_SEND_IF_OFF), - # AWAY and HOLD mode topics and templates are no longer supported, - # support was removed with release 2022.9 - cv.removed(CONF_AWAY_MODE_COMMAND_TOPIC), - cv.removed(CONF_AWAY_MODE_STATE_TEMPLATE), - cv.removed(CONF_AWAY_MODE_STATE_TOPIC), - cv.removed(CONF_HOLD_COMMAND_TEMPLATE), - cv.removed(CONF_HOLD_COMMAND_TOPIC), - cv.removed(CONF_HOLD_STATE_TEMPLATE), - cv.removed(CONF_HOLD_STATE_TOPIC), - cv.removed(CONF_HOLD_LIST), - # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, + # CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, # support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added # support was deprecated with release 2023.2 and will be removed with release 2023.8 - cv.deprecated(CONF_POWER_COMMAND_TOPIC), cv.deprecated(CONF_POWER_STATE_TEMPLATE), cv.deprecated(CONF_POWER_STATE_TOPIC), valid_preset_mode_configuration, @@ -443,6 +409,9 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): climate and water_heater platforms. """ + _attr_target_temperature_low: float | None + _attr_target_temperature_high: float | None + _optimistic: bool _topic: dict[str, Any] @@ -470,7 +439,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): ) -> None: """Add a subscription.""" qos: int = self._config[CONF_QOS] - if self._topic[topic] is not None: + if topic in self._topic and self._topic[topic] is not None: topics[topic] = { "topic": self._topic[topic], "msg_callback": msg_callback, @@ -599,15 +568,8 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): return changed @abstractmethod - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set hvac mode.""" - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" - operation_mode: HVACMode | None - if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: - await self.async_set_hvac_mode(operation_mode) - changed = await self._set_climate_attribute( kwargs.get(ATTR_TEMPERATURE), CONF_TEMP_COMMAND_TOPIC, @@ -637,7 +599,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): self.async_write_ha_state() -class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[misc] +class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): """Representation of an MQTT climate device.""" _entity_id_format = climate.ENTITY_ID_FORMAT @@ -668,28 +630,41 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[ def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" self._attr_hvac_modes = config[CONF_MODE_LIST] - self._attr_min_temp = config[CONF_TEMP_MIN] - self._attr_max_temp = config[CONF_TEMP_MAX] - self._attr_min_humidity = config[CONF_HUMIDITY_MIN] - self._attr_max_humidity = config[CONF_HUMIDITY_MAX] - self._attr_precision = config.get(CONF_PRECISION, super().precision) - self._attr_fan_modes = config[CONF_FAN_MODE_LIST] - self._attr_swing_modes = config[CONF_SWING_MODE_LIST] - self._attr_target_temperature_step = config[CONF_TEMP_STEP] + # Make sure the min an max temp is converted to the correct when not set self._attr_temperature_unit = config.get( CONF_TEMPERATURE_UNIT, self.hass.config.units.temperature_unit ) + if (min_temp := config.get(CONF_TEMP_MIN)) is not None: + self._attr_min_temp = min_temp + if (max_temp := config.get(CONF_TEMP_MAX)) is not None: + self._attr_max_temp = max_temp + self._attr_min_humidity = config[CONF_HUMIDITY_MIN] + self._attr_max_humidity = config[CONF_HUMIDITY_MAX] + if (precision := config.get(CONF_PRECISION)) is not None: + self._attr_precision = precision + self._attr_fan_modes = config[CONF_FAN_MODE_LIST] + self._attr_swing_modes = config[CONF_SWING_MODE_LIST] + self._attr_target_temperature_step = config[CONF_TEMP_STEP] self._topic = {key: config.get(key) for key in TOPIC_KEYS} self._optimistic = config[CONF_OPTIMISTIC] + # Set init temp, if it is missing convert the default to the temperature units + init_temp: float = config.get( + CONF_TEMP_INITIAL, + TemperatureConverter.convert( + DEFAULT_INITIAL_TEMPERATURE, + UnitOfTemperature.CELSIUS, + self.temperature_unit, + ), + ) if self._topic[CONF_TEMP_STATE_TOPIC] is None or self._optimistic: - self._attr_target_temperature = config[CONF_TEMP_INITIAL] + self._attr_target_temperature = init_temp if self._topic[CONF_TEMP_LOW_STATE_TOPIC] is None or self._optimistic: - self._attr_target_temperature_low = config[CONF_TEMP_INITIAL] + self._attr_target_temperature_low = init_temp if self._topic[CONF_TEMP_HIGH_STATE_TOPIC] is None or self._optimistic: - self._attr_target_temperature_high = config[CONF_TEMP_INITIAL] + self._attr_target_temperature_high = init_temp if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_fan_mode = FAN_LOW @@ -949,6 +924,13 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[ self.prepare_subscribe_topics(topics) + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + operation_mode: HVACMode | None + if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_set_hvac_mode(operation_mode) + await super().async_set_temperature(**kwargs) + async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" @@ -982,13 +964,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - if hvac_mode == HVACMode.OFF: - await self._publish( - CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_OFF] - ) - else: - await self._publish(CONF_POWER_COMMAND_TOPIC, self._config[CONF_PAYLOAD_ON]) - payload = self._command_templates[CONF_MODE_COMMAND_TEMPLATE](hvac_mode) await self._publish(CONF_MODE_COMMAND_TOPIC, payload) @@ -1033,3 +1008,28 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[ async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" await self._set_aux_heat(False) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + if CONF_POWER_COMMAND_TOPIC in self._config: + mqtt_payload = self._command_templates[CONF_POWER_COMMAND_TEMPLATE]( + self._config[CONF_PAYLOAD_ON] + ) + await self._publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload) + return + # Fall back to default behavior without power command topic + await super().async_turn_on() + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + if CONF_POWER_COMMAND_TOPIC in self._config: + mqtt_payload = self._command_templates[CONF_POWER_COMMAND_TEMPLATE]( + self._config[CONF_PAYLOAD_OFF] + ) + await self._publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload) + if self._optimistic: + self._attr_hvac_mode = HVACMode.OFF + self.async_write_ha_state() + return + # Fall back to default behavior without power command topic + await super().async_turn_off() diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 469f52e1488..ba2e0427ba7 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -24,6 +24,7 @@ from . import ( device_tracker as device_tracker_platform, fan as fan_platform, humidifier as humidifier_platform, + image as image_platform, light as light_platform, lock as lock_platform, number as number_platform, @@ -35,6 +36,7 @@ from . import ( text as text_platform, update as update_platform, vacuum as vacuum_platform, + water_heater as water_heater_platform, ) from .const import ( CONF_BIRTH_MESSAGE, @@ -88,6 +90,10 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( 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.LOCK.value: vol.All( cv.ensure_list, [lock_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] @@ -132,6 +138,10 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( 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] + ), } ) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c91c54a79a4..d09a2bb8cb6 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -29,6 +29,26 @@ CONF_WS_HEADERS = "ws_headers" CONF_WILL_MESSAGE = "will_message" CONF_PAYLOAD_RESET = "payload_reset" +CONF_ACTION_TEMPLATE = "action_template" +CONF_ACTION_TOPIC = "action_topic" +CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" +CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" +CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" +CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" +CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" +CONF_MODE_COMMAND_TOPIC = "mode_command_topic" +CONF_MODE_LIST = "modes" +CONF_MODE_STATE_TEMPLATE = "mode_state_template" +CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_PRECISION = "precision" +CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" +CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" +CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" +CONF_TEMP_STATE_TOPIC = "temperature_state_topic" +CONF_TEMP_INITIAL = "initial" +CONF_TEMP_MAX = "max_temp" +CONF_TEMP_MIN = "min_temp" + CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" @@ -95,6 +115,7 @@ PLATFORMS = [ Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, @@ -106,6 +127,7 @@ PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.WATER_HEATER, ] RELOADABLE_PLATFORMS = [ @@ -118,6 +140,7 @@ RELOADABLE_PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, + Platform.IMAGE, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, @@ -129,4 +152,5 @@ RELOADABLE_PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.WATER_HEATER, ] diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index da2f1b4496d..0b435db0b7a 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -200,13 +200,11 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( - cv.removed("tilt_invert_state"), _PLATFORM_SCHEMA_BASE, validate_options, ) DISCOVERY_SCHEMA = vol.All( - cv.removed("tilt_invert_state"), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), validate_options, ) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 0c0032ec8eb..70e5ac9e535 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -54,6 +54,7 @@ SUPPORTED_COMPONENTS = [ "device_tracker", "fan", "humidifier", + "image", "light", "lock", "number", @@ -66,6 +67,7 @@ SUPPORTED_COMPONENTS = [ "text", "update", "vacuum", + "water_heater", ] MQTT_DISCOVERY_UPDATED = "mqtt_discovery_updated_{}" diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index f00944fc091..392a112bcdb 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -10,10 +10,13 @@ import voluptuous as vol from homeassistant.components import humidifier from homeassistant.components.humidifier import ( + ATTR_ACTION, + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, + HumidifierAction, HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, @@ -35,8 +38,12 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription from .config import MQTT_RW_SCHEMA from .const import ( + CONF_ACTION_TEMPLATE, + CONF_ACTION_TOPIC, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_CURRENT_HUMIDITY_TEMPLATE, + CONF_CURRENT_HUMIDITY_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN, @@ -111,12 +118,16 @@ def valid_humidity_range_configuration(config: ConfigType) -> ConfigType: _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { + vol.Optional(CONF_ACTION_TEMPLATE): cv.template, + vol.Optional(CONF_ACTION_TOPIC): valid_subscribe_topic, # CONF_AVAIALABLE_MODES_LIST and CONF_MODE_COMMAND_TOPIC must be used together vol.Inclusive( CONF_AVAILABLE_MODES_LIST, "available_modes", default=[] ): cv.ensure_list, vol.Inclusive(CONF_MODE_COMMAND_TOPIC, "available_modes"): valid_publish_topic, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_CURRENT_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_CURRENT_HUMIDITY_TOPIC): valid_subscribe_topic, vol.Optional( CONF_DEVICE_CLASS, default=HumidifierDeviceClass.HUMIDIFIER ): vol.In( @@ -158,6 +169,17 @@ DISCOVERY_SCHEMA = vol.All( valid_mode_configuration, ) +TOPICS = ( + CONF_ACTION_TOPIC, + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC, + CONF_CURRENT_HUMIDITY_TOPIC, + CONF_TARGET_HUMIDITY_STATE_TOPIC, + CONF_TARGET_HUMIDITY_COMMAND_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_MODE_COMMAND_TOPIC, +) + async def async_setup_entry( hass: HomeAssistant, @@ -219,17 +241,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_min_humidity = config[CONF_TARGET_HUMIDITY_MIN] self._attr_max_humidity = config[CONF_TARGET_HUMIDITY_MAX] - self._topic = { - key: config.get(key) - for key in ( - CONF_STATE_TOPIC, - CONF_COMMAND_TOPIC, - CONF_TARGET_HUMIDITY_STATE_TOPIC, - CONF_TARGET_HUMIDITY_COMMAND_TOPIC, - CONF_MODE_STATE_TOPIC, - CONF_MODE_COMMAND_TOPIC, - ) - } + self._topic = {key: config.get(key) for key in TOPICS} self._payload = { "STATE_ON": config[CONF_PAYLOAD_ON], "STATE_OFF": config[CONF_PAYLOAD_OFF], @@ -242,6 +254,8 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_available_modes = [] if self._attr_available_modes: self._attr_supported_features = HumidifierEntityFeature.MODES + if CONF_MODE_STATE_TOPIC in config: + self._attr_mode = None optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None @@ -263,6 +277,8 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._value_templates = {} value_templates: dict[str, Template | None] = { + ATTR_ACTION: config.get(CONF_ACTION_TEMPLATE), + ATTR_CURRENT_HUMIDITY: config.get(CONF_CURRENT_HUMIDITY_TEMPLATE), CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), ATTR_HUMIDITY: config.get(CONF_TARGET_HUMIDITY_STATE_TEMPLATE), ATTR_MODE: config.get(CONF_MODE_STATE_TEMPLATE), @@ -273,6 +289,22 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): entity=self, ).async_render_with_possible_json_value + def add_subscription( + self, + topics: dict[str, dict[str, Any]], + topic: str, + msg_callback: Callable[[ReceiveMessage], None], + ) -> None: + """Add a subscription.""" + qos: int = self._config[CONF_QOS] + if topic in self._topic and self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": qos, + "encoding": self._config[CONF_ENCODING] or None, + } + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} @@ -293,13 +325,68 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_is_on = None get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_STATE_TOPIC] is not None: - topics[CONF_STATE_TOPIC] = { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + self.add_subscription(topics, CONF_STATE_TOPIC, state_received) + + @callback + @log_messages(self.hass, self.entity_id) + def action_received(msg: ReceiveMessage) -> None: + """Handle new received MQTT message.""" + action_payload = self._value_templates[ATTR_ACTION](msg.payload) + if not action_payload or action_payload == PAYLOAD_NONE: + _LOGGER.debug("Ignoring empty action from '%s'", msg.topic) + return + try: + self._attr_action = HumidifierAction(str(action_payload)) + except ValueError: + _LOGGER.error( + "'%s' received on topic %s. '%s' is not a valid action", + msg.payload, + msg.topic, + action_payload, + ) + return + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + self.add_subscription(topics, CONF_ACTION_TOPIC, action_received) + + @callback + @log_messages(self.hass, self.entity_id) + def current_humidity_received(msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the current humidity.""" + rendered_current_humidity_payload = self._value_templates[ + ATTR_CURRENT_HUMIDITY + ](msg.payload) + if rendered_current_humidity_payload == self._payload["HUMIDITY_RESET"]: + self._attr_current_humidity = None + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + return + if not rendered_current_humidity_payload: + _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic) + return + try: + current_humidity = round(float(rendered_current_humidity_payload)) + except ValueError: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid humidity", + msg.payload, + msg.topic, + rendered_current_humidity_payload, + ) + return + if current_humidity < 0 or current_humidity > 100: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid humidity", + msg.payload, + msg.topic, + rendered_current_humidity_payload, + ) + return + self._attr_current_humidity = current_humidity + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + self.add_subscription( + topics, CONF_CURRENT_HUMIDITY_TOPIC, current_humidity_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -339,14 +426,9 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_target_humidity = target_humidity get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC] is not None: - topics[CONF_TARGET_HUMIDITY_STATE_TOPIC] = { - "topic": self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC], - "msg_callback": target_humidity_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_target_humidity = None + self.add_subscription( + topics, CONF_TARGET_HUMIDITY_STATE_TOPIC, target_humidity_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -372,14 +454,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_mode = mode get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_MODE_STATE_TOPIC] is not None: - topics[CONF_MODE_STATE_TOPIC] = { - "topic": self._topic[CONF_MODE_STATE_TOPIC], - "msg_callback": mode_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_mode = None + self.add_subscription(topics, CONF_MODE_STATE_TOPIC, mode_received) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py new file mode 100644 index 00000000000..2764539770d --- /dev/null +++ b/homeassistant/components/mqtt/image.py @@ -0,0 +1,223 @@ +"""Support for MQTT images.""" +from __future__ import annotations + +from base64 import b64decode +import binascii +from collections.abc import Callable +import functools +import logging +from typing import Any + +import httpx +import voluptuous as vol + +from homeassistant.components import image +from homeassistant.components.image import ( + DEFAULT_CONTENT_TYPE, + ImageEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util + +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 .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage +from .util import get_mqtt_data, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +CONF_CONTENT_TYPE = "content_type" +CONF_IMAGE_ENCODING = "image_encoding" +CONF_IMAGE_TOPIC = "image_topic" +CONF_URL_TEMPLATE = "url_template" +CONF_URL_TOPIC = "url_topic" + +DEFAULT_NAME = "MQTT Image" + +GET_IMAGE_TIMEOUT = 10 + + +def validate_topic_required(config: ConfigType) -> ConfigType: + """Ensure at least one subscribe topic is configured.""" + if CONF_IMAGE_TOPIC not in config and CONF_URL_TOPIC not in config: + raise vol.Invalid("Expected one of [`image_topic`, `url_topic`], got none") + if CONF_CONTENT_TYPE in config and CONF_URL_TOPIC in config: + raise vol.Invalid( + "Option `content_type` can not be used together with `url_topic`" + ) + return config + + +PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_CONTENT_TYPE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Exclusive(CONF_URL_TOPIC, "image_topic"): valid_subscribe_topic, + vol.Exclusive(CONF_IMAGE_TOPIC, "image_topic"): valid_subscribe_topic, + vol.Optional(CONF_IMAGE_ENCODING): "b64", + vol.Optional(CONF_URL_TEMPLATE): cv.template, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All(PLATFORM_SCHEMA_BASE.schema, validate_topic_required) + +DISCOVERY_SCHEMA = vol.All( + PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), validate_topic_required +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + 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_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): + """representation of a MQTT image.""" + + _entity_id_format: str = image.ENTITY_ID_FORMAT + _last_image: bytes | None = None + _client: httpx.AsyncClient + _url_template: Callable[[ReceivePayloadType], ReceivePayloadType] + _topic: dict[str, Any] + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the MQTT Image.""" + self._client = get_async_client(hass) + ImageEntity.__init__(self, hass) + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._topic = { + key: config.get(key) + for key in ( + CONF_IMAGE_TOPIC, + CONF_URL_TOPIC, + ) + } + if CONF_IMAGE_TOPIC in config: + self._attr_content_type = config.get( + CONF_CONTENT_TYPE, DEFAULT_CONTENT_TYPE + ) + if CONF_URL_TOPIC in config: + self._attr_image_url = None + self._url_template = MqttValueTemplate( + config.get(CONF_URL_TEMPLATE), entity=self + ).async_render_with_possible_json_value + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + topics: dict[str, Any] = {} + + def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: + """Add a topic to subscribe to.""" + encoding: str | None + encoding = ( + None + if CONF_IMAGE_TOPIC in self._config + else self._config[CONF_ENCODING] or None + ) + if has_topic := self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": self._config[CONF_QOS], + "encoding": encoding, + } + return has_topic + + @callback + @log_messages(self.hass, self.entity_id) + def image_data_received(msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + try: + if CONF_IMAGE_ENCODING in self._config: + self._last_image = b64decode(msg.payload) + else: + assert isinstance(msg.payload, bytes) + self._last_image = msg.payload + except (binascii.Error, ValueError, AssertionError) as err: + _LOGGER.error( + "Error processing image data received at topic %s: %s", + msg.topic, + err, + ) + self._last_image = None + self._attr_image_last_updated = dt_util.utcnow() + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscribe_topic(CONF_IMAGE_TOPIC, image_data_received) + + @callback + @log_messages(self.hass, self.entity_id) + def image_from_url_request_received(msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + + try: + url = cv.url(self._url_template(msg.payload)) + self._attr_image_url = url + except vol.Invalid: + _LOGGER.error( + "Invalid image URL '%s' received at topic %s", + msg.payload, + msg.topic, + ) + self._attr_image_last_updated = dt_util.utcnow() + self._cached_image = None + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscribe_topic(CONF_URL_TOPIC, image_from_url_request_received) + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + if CONF_IMAGE_TOPIC in self._config: + return self._last_image + return await super().async_image() diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 5cd42ef1934..2c70490ac5e 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -69,7 +69,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up MQTT lights configured under the light platform key (deprecated).""" + """Set up MQTT lights through YAML and through MQTT discovery.""" setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index b3659a67e61..7f2c2cf5e06 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -111,10 +111,6 @@ CONF_XY_STATE_TOPIC = "xy_state_topic" CONF_XY_VALUE_TEMPLATE = "xy_value_template" CONF_WHITE_COMMAND_TOPIC = "white_command_topic" CONF_WHITE_SCALE = "white_scale" -CONF_WHITE_VALUE_COMMAND_TOPIC = "white_value_command_topic" -CONF_WHITE_VALUE_SCALE = "white_value_scale" -CONF_WHITE_VALUE_STATE_TOPIC = "white_value_state_topic" -CONF_WHITE_VALUE_TEMPLATE = "white_value_template" CONF_ON_COMMAND_TYPE = "on_command_type" MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( @@ -167,7 +163,7 @@ VALUE_TEMPLATE_KEYS = [ CONF_XY_VALUE_TEMPLATE, ] -_PLATFORM_SCHEMA_BASE = ( +PLATFORM_SCHEMA_MODERN_BASIC = ( MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_BRIGHTNESS_COMMAND_TEMPLATE): cv.template, @@ -228,21 +224,7 @@ _PLATFORM_SCHEMA_BASE = ( ) DISCOVERY_SCHEMA_BASIC = vol.All( - # CONF_WHITE_VALUE_* is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE_COMMAND_TOPIC), - cv.removed(CONF_WHITE_VALUE_SCALE), - cv.removed(CONF_WHITE_VALUE_STATE_TOPIC), - cv.removed(CONF_WHITE_VALUE_TEMPLATE), - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), -) - -PLATFORM_SCHEMA_MODERN_BASIC = vol.All( - # CONF_WHITE_VALUE_* is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE_COMMAND_TOPIC), - cv.removed(CONF_WHITE_VALUE_SCALE), - cv.removed(CONF_WHITE_VALUE_STATE_TOPIC), - cv.removed(CONF_WHITE_VALUE_TEMPLATE), - _PLATFORM_SCHEMA_BASE, + PLATFORM_SCHEMA_MODERN_BASIC.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index c40dae659b7..70992887ca7 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -101,8 +101,6 @@ CONF_FLASH_TIME_SHORT = "flash_time_short" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" -CONF_WHITE_VALUE = "white_value" - def valid_color_configuration(config: ConfigType) -> ConfigType: """Test color_mode is not combined with deprecated config.""" @@ -158,15 +156,11 @@ _PLATFORM_SCHEMA_BASE = ( ) DISCOVERY_SCHEMA_JSON = vol.All( - # CONF_WHITE_VALUE is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), valid_color_configuration, ) PLATFORM_SCHEMA_MODERN_JSON = vol.All( - # CONF_WHITE_VALUE is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE), _PLATFORM_SCHEMA_BASE, valid_color_configuration, ) diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index c2b4de289fd..063895d738c 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -75,7 +75,6 @@ CONF_GREEN_TEMPLATE = "green_template" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" CONF_RED_TEMPLATE = "red_template" -CONF_WHITE_VALUE_TEMPLATE = "white_value_template" COMMAND_TEMPLATES = (CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_OFF_TEMPLATE) VALUE_TEMPLATES = ( @@ -88,7 +87,7 @@ VALUE_TEMPLATES = ( CONF_STATE_TEMPLATE, ) -_PLATFORM_SCHEMA_BASE = ( +PLATFORM_SCHEMA_MODERN_TEMPLATE = ( MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_BLUE_TEMPLATE): cv.template, @@ -111,15 +110,7 @@ _PLATFORM_SCHEMA_BASE = ( ) DISCOVERY_SCHEMA_TEMPLATE = vol.All( - # CONF_WHITE_VALUE_TEMPLATE is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE_TEMPLATE), - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), -) - -PLATFORM_SCHEMA_MODERN_TEMPLATE = vol.All( - # CONF_WHITE_VALUE_TEMPLATE is no longer supported, support was removed in 2022.9 - cv.removed(CONF_WHITE_VALUE_TEMPLATE), - _PLATFORM_SCHEMA_BASE, + PLATFORM_SCHEMA_MODERN_TEMPLATE.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 46744c4d65d..34b61d89c48 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -244,6 +244,20 @@ class SetupEntity(Protocol): """Define setup_entities type.""" +@callback +def async_handle_schema_error( + discovery_payload: MQTTDiscoveryPayload, err: vol.MultipleInvalid +) -> None: + """Help handling schema errors on MQTT discovery messages.""" + discovery_topic: str = discovery_payload.discovery_data[ATTR_DISCOVERY_TOPIC] + _LOGGER.error( + "Error '%s' when processing MQTT discovery message topic: '%s', message: '%s'", + err, + discovery_topic, + discovery_payload, + ) + + async def async_setup_entry_helper( hass: HomeAssistant, domain: str, @@ -269,8 +283,15 @@ async def async_setup_entry_helper( 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: tuple[str, str] = discovery_data[ATTR_DISCOVERY_HASH] + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] clear_discovery_hash(hass, discovery_hash) async_dispatcher_send( hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None @@ -1037,7 +1058,11 @@ class MqttEntity( async def discovery_update(self, discovery_payload: MQTTDiscoveryPayload) -> None: """Handle updated discovery message.""" - config: DiscoveryInfoType = self.config_schema()(discovery_payload) + try: + config: DiscoveryInfoType = self.config_schema()(discovery_payload) + except vol.Invalid as err: + async_handle_schema_error(discovery_payload, err) + return self._config = config self._setup_common_attributes_from_config(self._config) self._setup_from_config(self._config) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index c0cb00211e3..5986eab1207 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -186,6 +186,9 @@ class MqttNumber(MqttEntity, RestoreNumber): """Handle new MQTT messages.""" num_value: int | float | None payload = str(self._value_template(msg.payload)) + if not payload.strip(): + _LOGGER.debug("Ignoring empty state update from '%s'", msg.topic) + return try: if payload == self._config[CONF_PAYLOAD_RESET]: num_value = None diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 7ccc31bd335..dda80bba84e 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -8,8 +8,8 @@ import attr from homeassistant.core import HomeAssistant -from . import debug_info from .. import mqtt +from . import debug_info from .const import DEFAULT_QOS from .models import MessageCallbackType diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py new file mode 100644 index 00000000000..0f622d55b84 --- /dev/null +++ b/homeassistant/components/mqtt/water_heater.py @@ -0,0 +1,318 @@ +"""Support for MQTT water heater devices.""" +from __future__ import annotations + +import functools +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import water_heater +from homeassistant.components.water_heater import ( + ATTR_OPERATION_MODE, + DEFAULT_MIN_TEMP, + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_NAME, + CONF_OPTIMISTIC, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, + CONF_TEMPERATURE_UNIT, + CONF_VALUE_TEMPLATE, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + STATE_OFF, + UnitOfTemperature, +) +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.util.unit_conversion import TemperatureConverter + +from .climate import MqttTemperatureControlEntity +from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA +from .const import ( + CONF_CURRENT_TEMP_TEMPLATE, + CONF_CURRENT_TEMP_TOPIC, + CONF_MODE_COMMAND_TEMPLATE, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_LIST, + CONF_MODE_STATE_TEMPLATE, + CONF_MODE_STATE_TOPIC, + CONF_PRECISION, + CONF_RETAIN, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_INITIAL, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + CONF_TEMP_STATE_TEMPLATE, + CONF_TEMP_STATE_TOPIC, + DEFAULT_OPTIMISTIC, +) +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, async_setup_entry_helper +from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage +from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "MQTT Water Heater" + +MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED = frozenset( + { + water_heater.ATTR_CURRENT_TEMPERATURE, + water_heater.ATTR_MAX_TEMP, + water_heater.ATTR_MIN_TEMP, + water_heater.ATTR_TEMPERATURE, + water_heater.ATTR_OPERATION_LIST, + water_heater.ATTR_OPERATION_MODE, + } +) + +VALUE_TEMPLATE_KEYS = ( + CONF_CURRENT_TEMP_TEMPLATE, + CONF_MODE_STATE_TEMPLATE, + CONF_TEMP_STATE_TEMPLATE, +) + +COMMAND_TEMPLATE_KEYS = { + CONF_MODE_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TEMPLATE, +} + + +TOPIC_KEYS = ( + CONF_CURRENT_TEMP_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_STATE_TOPIC, +) + + +_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_CURRENT_TEMP_TEMPLATE): cv.template, + vol.Optional(CONF_CURRENT_TEMP_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_MODE_COMMAND_TOPIC): valid_publish_topic, + vol.Optional( + CONF_MODE_LIST, + default=[ + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, + STATE_OFF, + ], + ): cv.ensure_list, + vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, + vol.Optional(CONF_PRECISION): vol.In( + [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] + ), + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_TEMP_INITIAL): cv.positive_int, + vol.Optional(CONF_TEMP_MIN): vol.Coerce(float), + vol.Optional(CONF_TEMP_MAX): vol.Coerce(float), + vol.Optional(CONF_TEMP_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_TEMP_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_TEMP_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMP_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All( + _PLATFORM_SCHEMA_BASE, +) + +_DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) + +DISCOVERY_SCHEMA = vol.All( + _DISCOVERY_SCHEMA_BASE, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + 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_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): + """Representation of an MQTT water heater device.""" + + _entity_id_format = water_heater.ENTITY_ID_FORMAT + _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the water heater device.""" + MqttTemperatureControlEntity.__init__( + self, hass, config, config_entry, discovery_data + ) + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._attr_operation_list = config[CONF_MODE_LIST] + self._attr_temperature_unit = config.get( + CONF_TEMPERATURE_UNIT, self.hass.config.units.temperature_unit + ) + if (min_temp := config.get(CONF_TEMP_MIN)) is not None: + self._attr_min_temp = min_temp + if (max_temp := config.get(CONF_TEMP_MAX)) is not None: + self._attr_max_temp = max_temp + if (precision := config.get(CONF_PRECISION)) is not None: + self._attr_precision = precision + + self._topic = {key: config.get(key) for key in TOPIC_KEYS} + + self._optimistic = config[CONF_OPTIMISTIC] + + # Set init temp, if it is missing convert the default to the temperature units + init_temp: float = config.get( + CONF_TEMP_INITIAL, + TemperatureConverter.convert( + DEFAULT_MIN_TEMP, + UnitOfTemperature.FAHRENHEIT, + self.temperature_unit, + ), + ) + if self._topic[CONF_TEMP_STATE_TOPIC] is None or self._optimistic: + self._attr_target_temperature = init_temp + if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: + self._attr_current_operation = STATE_OFF + + value_templates: dict[str, Template | None] = {} + for key in VALUE_TEMPLATE_KEYS: + value_templates[key] = None + if CONF_VALUE_TEMPLATE in config: + value_templates = { + key: config.get(CONF_VALUE_TEMPLATE) for key in VALUE_TEMPLATE_KEYS + } + for key in VALUE_TEMPLATE_KEYS & config.keys(): + value_templates[key] = config[key] + self._value_templates = { + key: MqttValueTemplate( + template, + entity=self, + ).async_render_with_possible_json_value + for key, template in value_templates.items() + } + + self._command_templates = {} + for key in COMMAND_TEMPLATE_KEYS: + self._command_templates[key] = MqttCommandTemplate( + config.get(key), entity=self + ).async_render + + support = WaterHeaterEntityFeature(0) + if (self._topic[CONF_TEMP_STATE_TOPIC] is not None) or ( + self._topic[CONF_TEMP_COMMAND_TOPIC] is not None + ): + support |= WaterHeaterEntityFeature.TARGET_TEMPERATURE + + if (self._topic[CONF_MODE_STATE_TOPIC] is not None) or ( + self._topic[CONF_MODE_COMMAND_TOPIC] is not None + ): + support |= WaterHeaterEntityFeature.OPERATION_MODE + + self._attr_supported_features = support + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics: dict[str, dict[str, Any]] = {} + + @callback + def handle_mode_received( + msg: ReceiveMessage, template_name: str, attr: str, mode_list: str + ) -> None: + """Handle receiving listed mode via MQTT.""" + payload = self.render_template(msg, template_name) + + if payload not in self._config[mode_list]: + _LOGGER.error("Invalid %s mode: %s", mode_list, payload) + else: + setattr(self, attr, payload) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_current_mode_received(msg: ReceiveMessage) -> None: + """Handle receiving operation mode via MQTT.""" + handle_mode_received( + msg, + CONF_MODE_STATE_TEMPLATE, + "_attr_current_operation", + CONF_MODE_LIST, + ) + + self.add_subscription( + topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received + ) + + self.prepare_subscribe_topics(topics) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + operation_mode: str | None + if (operation_mode := kwargs.get(ATTR_OPERATION_MODE)) is not None: + await self.async_set_operation_mode(operation_mode) + await super().async_set_temperature(**kwargs) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + payload = self._command_templates[CONF_MODE_COMMAND_TEMPLATE](operation_mode) + await self._publish(CONF_MODE_COMMAND_TOPIC, payload) + + if self._optimistic or self._topic[CONF_MODE_STATE_TOPIC] is None: + self._attr_current_operation = operation_mode + self.async_write_ha_state() diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 05f698f2170..5e03f962d15 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -1,7 +1,7 @@ { "domain": "myq", "name": "MyQ", - "codeowners": ["@bdraco", "@ehendrix23"], + "codeowners": ["@ehendrix23"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index d7405dba187..42c5a40636e 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -227,7 +227,6 @@ class MySensorsEntity(MySensorsDevice, Entity): """Return entity specific state attributes.""" attr = self._extra_attributes - assert self.platform assert self.platform.config_entry attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index dc5dc76c7ae..7e0ff2c99d6 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -19,7 +19,7 @@ "description": "Ethernet gateway setup", "data": { "device": "IP address of the gateway", - "tcp_port": "port", + "tcp_port": "[%key:common::config_flow::data::port%]", "version": "MySensors version", "persistence_file": "persistence file (leave empty to auto-generate)" } @@ -30,17 +30,17 @@ "device": "Serial port", "baud_rate": "baud rate", "version": "MySensors version", - "persistence_file": "persistence file (leave empty to auto-generate)" + "persistence_file": "Persistence file (leave empty to auto-generate)" } }, "gw_mqtt": { "description": "MQTT gateway setup", "data": { - "retain": "mqtt retain", - "topic_in_prefix": "prefix for input topics (topic_in_prefix)", - "topic_out_prefix": "prefix for output topics (topic_out_prefix)", + "retain": "MQTT retain", + "topic_in_prefix": "Prefix for input topics (topic_in_prefix)", + "topic_out_prefix": "Prefix for output topics (topic_out_prefix)", "version": "MySensors version", - "persistence_file": "persistence file (leave empty to auto-generate)" + "persistence_file": "Persistence file (leave empty to auto-generate)" } } }, diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py index 54a24b9b4af..160cd0e8634 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -1 +1,79 @@ -"""The mystrom component.""" +"""The myStrom integration.""" +from __future__ import annotations + +import logging + +import pymystrom +from pymystrom.bulb import MyStromBulb +from pymystrom.exceptions import MyStromConnectionError +from pymystrom.switch import MyStromSwitch + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .models import MyStromData + +PLATFORMS_SWITCH = [Platform.SWITCH] +PLATFORMS_BULB = [Platform.LIGHT] + +_LOGGER = logging.getLogger(__name__) + + +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: + _LOGGER.error("No route to myStrom plug: %s", host) + raise ConfigEntryNotReady() from err + + device_type = info["type"] + if device_type in [101, 106, 107]: + device = MyStromSwitch(host) + platforms = PLATFORMS_SWITCH + elif device_type == 102: + mac = info["mac"] + device = MyStromBulb(host, mac) + platforms = PLATFORMS_BULB + if device.bulb_type not in ["rgblamp", "strip"]: + _LOGGER.error( + "Device %s (%s) is not a myStrom bulb nor myStrom LED Strip", + host, + mac, + ) + return False + else: + _LOGGER.error("Unsupported myStrom device type: %s", device_type) + return False + + try: + await device.get_state() + except MyStromConnectionError as err: + _LOGGER.error("No route to myStrom plug: %s", info["ip"]) + raise ConfigEntryNotReady() from err + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = MyStromData( + device=device, + info=info, + ) + 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.""" + device_type = hass.data[DOMAIN][entry.entry_id].info["type"] + if device_type in [101, 106, 107]: + platforms = PLATFORMS_SWITCH + elif device_type == 102: + platforms = PLATFORMS_BULB + 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/mystrom/config_flow.py b/homeassistant/components/mystrom/config_flow.py new file mode 100644 index 00000000000..3dc334d8252 --- /dev/null +++ b/homeassistant/components/mystrom/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for myStrom integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import pymystrom +from pymystrom.exceptions import MyStromConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "myStrom Device" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for myStrom.""" + + VERSION = 1 + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Handle import from config.""" + return await self.async_step_user(import_config) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + info = await pymystrom.get_device_info(user_input[CONF_HOST]) + except MyStromConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(info["mac"]) + self._abort_if_unique_id_configured() + data = {CONF_HOST: user_input[CONF_HOST]} + title = user_input.get(CONF_NAME) or DEFAULT_NAME + return self.async_create_entry(title=title, data=data) + + schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/mystrom/const.py b/homeassistant/components/mystrom/const.py index 87697acbe96..5641463abf1 100644 --- a/homeassistant/components/mystrom/const.py +++ b/homeassistant/components/mystrom/const.py @@ -1,2 +1,4 @@ """Constants for the myStrom integration.""" DOMAIN = "mystrom" +DEFAULT_NAME = "myStrom" +MANUFACTURER = "myStrom" diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index e01cebb818d..14badde17d2 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging from typing import Any -from pymystrom.bulb import MyStromBulb from pymystrom.exceptions import MyStromConnectionError import voluptuous as vol @@ -17,13 +16,17 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN, MANUFACTURER + _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "myStrom bulb" @@ -40,6 +43,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the myStrom entities.""" + info = hass.data[DOMAIN][entry.entry_id].info + device = hass.data[DOMAIN][entry.entry_id].device + async_add_entities([MyStromLight(device, entry.title, info["mac"])]) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -47,23 +59,20 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the myStrom light integration.""" - host = config.get(CONF_HOST) - mac = config.get(CONF_MAC) - name = config.get(CONF_NAME) - - bulb = MyStromBulb(host, mac) - try: - await bulb.get_state() - if bulb.bulb_type not in ["rgblamp", "strip"]: - _LOGGER.error( - "Device %s (%s) is not a myStrom bulb nor myStrom LED Strip", host, mac - ) - return - except MyStromConnectionError as err: - _LOGGER.warning("No route to myStrom bulb: %s", host) - raise PlatformNotReady() from err - - async_add_entities([MyStromLight(bulb, name, mac)], True) + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) class MyStromLight(LightEntity): @@ -81,6 +90,12 @@ class MyStromLight(LightEntity): self._attr_available = False self._attr_unique_id = mac self._attr_hs_color = 0, 0 + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac)}, + name=name, + manufacturer=MANUFACTURER, + sw_version=self._bulb.firmware, + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index 7659b1d8025..eaf9eb6acdc 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -2,6 +2,7 @@ "domain": "mystrom", "name": "myStrom", "codeowners": ["@fabaff"], + "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/mystrom", "iot_class": "local_polling", diff --git a/homeassistant/components/mystrom/models.py b/homeassistant/components/mystrom/models.py new file mode 100644 index 00000000000..96cc40996ef --- /dev/null +++ b/homeassistant/components/mystrom/models.py @@ -0,0 +1,14 @@ +"""Models for the mystrom integration.""" +from dataclasses import dataclass +from typing import Any + +from pymystrom.bulb import MyStromBulb +from pymystrom.switch import MyStromSwitch + + +@dataclass +class MyStromData: + """Data class for mystrom device data.""" + + device: MyStromSwitch | MyStromBulb + info: dict[str, Any] diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json new file mode 100644 index 00000000000..259501e1486 --- /dev/null +++ b/homeassistant/components/mystrom/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The myStrom YAML configuration is being removed", + "description": "Configuring myStrom using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the myStrom YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 3d073693b1e..8e89bb5f151 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -5,17 +5,20 @@ import logging from typing import Any from pymystrom.exceptions import MyStromConnectionError -from pymystrom.switch import MyStromSwitch as _MyStromSwitch import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN, MANUFACTURER + DEFAULT_NAME = "myStrom Switch" _LOGGER = logging.getLogger(__name__) @@ -28,6 +31,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the myStrom entities.""" + device = hass.data[DOMAIN][entry.entry_id].device + async_add_entities([MyStromSwitch(device, entry.title)]) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -35,17 +46,20 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the myStrom switch/plug integration.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - - try: - plug = _MyStromSwitch(host) - await plug.get_state() - except MyStromConnectionError as err: - _LOGGER.error("No route to myStrom plug: %s", host) - raise PlatformNotReady() from err - - async_add_entities([MyStromSwitch(plug, name)]) + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) class MyStromSwitch(SwitchEntity): @@ -56,6 +70,12 @@ class MyStromSwitch(SwitchEntity): self.plug = plug self._attr_name = name self._attr_unique_id = self.plug.mac + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.plug.mac)}, + name=name, + manufacturer=MANUFACTURER, + sw_version=self.plug.firmware, + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" diff --git a/homeassistant/components/nad/manifest.json b/homeassistant/components/nad/manifest.json index dd354086a1a..2e2d44341af 100644 --- a/homeassistant/components/nad/manifest.json +++ b/homeassistant/components/nad/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/nad", "iot_class": "local_polling", "loggers": ["nad_receiver"], - "requirements": ["nad_receiver==0.3.0"] + "requirements": ["nad-receiver==0.3.0"] } diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index c81c3e0c7f7..c1d97f781af 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -1,6 +1,7 @@ """Support for Ness D8X/D16X devices.""" from collections import namedtuple import datetime +import logging from nessclient import ArmingState, Client import voluptuous as vol @@ -21,8 +22,11 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType +_LOGGER = logging.getLogger(__name__) + DOMAIN = "ness_alarm" DATA_NESS = "ness_alarm" @@ -109,6 +113,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close) + async def _started(event): + # Force update for current arming status and current zone states (once Home Assistant has finished loading required sensors and panel) + _LOGGER.debug("invoking client keepalive() & update()") + hass.loop.create_task(client.keepalive()) + hass.loop.create_task(client.update()) + + async_at_started(hass, _started) + hass.async_create_task( async_load_platform( hass, Platform.BINARY_SENSOR, DOMAIN, {CONF_ZONES: zones}, config @@ -131,10 +143,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client.on_zone_change(on_zone_change) client.on_state_change(on_state_change) - # Force update for current arming status and current zone states - hass.loop.create_task(client.keepalive()) - hass.loop.create_task(client.update()) - async def handle_panic(call: ServiceCall) -> None: await client.panic(call.data[ATTR_CODE]) diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 834202ba58d..3eceb448fa4 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -61,6 +61,7 @@ class NestCamera(Camera): """Devices that support cameras.""" _attr_has_entity_name = True + _attr_name = None def __init__(self, device: Device) -> None: """Initialize the camera.""" diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index ab0ce20a9a1..ca975ed055d 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -99,6 +99,7 @@ class ThermostatEntity(ClimateEntity): _attr_max_temp = MAX_TEMP _attr_has_entity_name = True _attr_should_poll = False + _attr_name = None def __init__(self, device: Device) -> None: """Initialize ThermostatEntity.""" diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 6af293aba97..dbb30ceb52a 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", "nest"], "quality_scale": "platinum", - "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.2.4"] + "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.2.5"] } diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index aa8728d548d..e26a32965a3 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -234,11 +234,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await register_webhook(None) cloud.async_listen_connection_change(hass, manage_cloudhook) + elif hass.state == CoreState.running: + await register_webhook(None) else: - if hass.state == CoreState.running: - await register_webhook(None) - else: - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, register_webhook) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, register_webhook) hass.services.async_register(DOMAIN, "register_webhook", register_webhook) hass.services.async_register(DOMAIN, "unregister_webhook", unregister_webhook) diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index f3f45458d78..f4719badcfa 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -56,7 +56,7 @@ TRIGGER_TYPES = OUTDOOR_CAMERA_TRIGGERS + INDOOR_CAMERA_TRIGGERS + CLIMATE_TRIGG TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), vol.Optional(CONF_SUBTYPE): str, } @@ -111,7 +111,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: trigger, CONF_SUBTYPE: subtype, } @@ -122,7 +122,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: trigger, } ) diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index a500689a937..5fdf580c6aa 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -47,9 +47,9 @@ }, "device_automation": { "trigger_subtype": { - "away": "away", - "schedule": "schedule", - "hg": "frost guard" + "away": "Away", + "schedule": "Schedule", + "hg": "Frost guard" }, "trigger_type": { "turned_off": "{entity_name} turned off", diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 8f81de43ebb..ef31a887691 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -208,7 +208,7 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: - """Remove a config entry from a device.""" + """Remove a device from a config entry.""" router = hass.data[DOMAIN][config_entry.entry_id][KEY_ROUTER] device_mac = None diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 30ff2280408..32bb9a574cd 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -119,6 +119,32 @@ async def async_get_ipv4_broadcast_addresses(hass: HomeAssistant) -> set[IPv4Add return broadcast_addresses +async def async_get_announce_addresses(hass: HomeAssistant) -> list[str]: + """Return a list of IP addresses to announce/use via zeroconf/ssdp/etc. + + The default ip address is always returned first if available. + """ + adapters = await async_get_adapters(hass) + addresses: list[str] = [] + default_ip: str | None = None + for adapter in adapters: + if not adapter["enabled"]: + continue + for ips in adapter["ipv4"]: + addresses.append(str(IPv4Address(ips["address"]))) + for ips in adapter["ipv6"]: + addresses.append(str(IPv6Address(ips["address"]))) + + # Puts the default IPv4 address first in the list to preserve compatibility, + # because some mDNS implementations ignores anything but the first announced + # address. + if default_ip := await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP): + if default_ip in addresses: + addresses.remove(default_ip) + return [default_ip] + list(addresses) + return list(addresses) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" # Avoid circular issue: http->network->websocket_api->http diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index b77ffa86f03..4b8bd1a9294 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -5,5 +5,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==0.1.5"] } diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 02f5d8695ca..b8f36e10fa1 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -108,41 +108,19 @@ class NextBusDepartureSensor(SensorEntity): self.agency = agency self.route = route self.stop = stop - self._custom_name = name - # Maybe pull a more user friendly name from the API here - self._name = f"{agency} {route}" - self._client = client + self._attr_extra_state_attributes = {} - # set up default state attributes - self._state = None - self._attributes = {} + # Maybe pull a more user friendly name from the API here + self._attr_name = f"{agency} {route}" + if name: + 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) - @property - def name(self): - """Return sensor name. - - Uses an auto generated name based on the data from the API unless a - custom name is provided in the configuration. - """ - if self._custom_name: - return self._custom_name - - return self._name - - @property - def native_value(self): - """Return current state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return additional state attributes.""" - return self._attributes - def update(self) -> None: """Update sensor with new departures times.""" # Note: using Multi because there is a bug with the single stop impl @@ -151,21 +129,22 @@ class NextBusDepartureSensor(SensorEntity): ) 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") - self._state = None + self._attr_native_value = None # Remove attributes that may now be outdated - self._attributes.pop("upcoming", None) + self._attr_extra_state_attributes.pop("upcoming", None) return results = results["predictions"] # Set detailed attributes - self._attributes.update( + self._attr_extra_state_attributes.update( { "agency": results.get("agencyTitle"), "route": results.get("routeTitle"), @@ -176,13 +155,13 @@ class NextBusDepartureSensor(SensorEntity): # List all messages in the attributes messages = listify(results.get("message", [])) self._log_debug("Messages: %s", messages) - self._attributes["message"] = " -- ".join( + self._attr_extra_state_attributes["message"] = " -- ".join( message.get("text", "") for message in messages ) # List out all directions in the attributes directions = listify(results.get("direction", [])) - self._attributes["direction"] = ", ".join( + self._attr_extra_state_attributes["direction"] = ", ".join( direction.get("title", "") for direction in directions ) @@ -196,14 +175,16 @@ class NextBusDepartureSensor(SensorEntity): # Short circuit if we don't have any actual bus predictions if not predictions: self._log_debug("No upcoming predictions available") - self._state = None - self._attributes["upcoming"] = "No upcoming predictions" + self._attr_native_value = None + self._attr_extra_state_attributes["upcoming"] = "No upcoming predictions" return # Generate list of upcoming times - self._attributes["upcoming"] = ", ".join( + self._attr_extra_state_attributes["upcoming"] = ", ".join( sorted((p["minutes"] for p in predictions), key=int) ) latest_prediction = maybe_first(predictions) - self._state = utc_from_timestamp(int(latest_prediction["epochTime"]) / 1000) + self._attr_native_value = utc_from_timestamp( + int(latest_prediction["epochTime"]) / 1000 + ) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index b46102879c4..a38e2182ad7 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -5,7 +5,6 @@ import asyncio from collections import defaultdict from collections.abc import Callable, Iterable from datetime import timedelta -from functools import cached_property from typing import Any, Generic, TypeVar from nibe.coil import Coil, CoilData @@ -15,6 +14,7 @@ from nibe.connection.nibegw import NibeGW, ProductInfo from nibe.exceptions import CoilNotFoundException, ReadException from nibe.heatpump import HeatPump, Model, Series +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_IP_ADDRESS, diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 88d61a427b5..fbb8e32bebe 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +import re from typing import Any from async_timeout import timeout @@ -13,7 +14,15 @@ 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, CONF_FILTER_CORONA, CONF_REGIONS, DOMAIN, SCAN_INTERVAL +from .const import ( + _LOGGER, + CONF_FILTER_CORONA, + CONF_HEADLINE_FILTER, + CONF_REGIONS, + DOMAIN, + NO_MATCH_REGEX, + SCAN_INTERVAL, +) PLATFORMS: list[str] = [Platform.BINARY_SENSOR] @@ -23,8 +32,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: regions: dict[str, str] = entry.data[CONF_REGIONS] + if CONF_HEADLINE_FILTER not in entry.data: + filter_regex = NO_MATCH_REGEX + + if entry.data[CONF_FILTER_CORONA]: + filter_regex = ".*corona.*" + + new_data = {**entry.data, CONF_HEADLINE_FILTER: filter_regex} + new_data.pop(CONF_FILTER_CORONA, None) + hass.config_entries.async_update_entry(entry, data=new_data) + coordinator = NINADataUpdateCoordinator( - hass, regions, entry.data[CONF_FILTER_CORONA] + hass, regions, entry.data[CONF_HEADLINE_FILTER] ) await coordinator.async_config_entry_first_refresh() @@ -70,12 +89,12 @@ class NINADataUpdateCoordinator( """Class to manage fetching NINA data API.""" def __init__( - self, hass: HomeAssistant, regions: dict[str, str], corona_filter: bool + self, hass: HomeAssistant, regions: dict[str, str], headline_filter: str ) -> None: """Initialize.""" self._regions: dict[str, str] = regions self._nina: Nina = Nina(async_get_clientsession(hass)) - self.corona_filter: bool = corona_filter + self.headline_filter: str = headline_filter for region in regions: self._nina.addRegion(region) @@ -125,7 +144,9 @@ class NINADataUpdateCoordinator( warnings_for_regions: list[NinaWarningData] = [] for raw_warn in raw_warnings: - if "corona" in raw_warn.headline.lower() and self.corona_filter: + if re.search( + self.headline_filter, raw_warn.headline, flags=re.IGNORECASE + ): continue warning_data: NinaWarningData = NinaWarningData( diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index f1579bc05ec..d41fa6dee3e 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -18,12 +18,13 @@ from homeassistant.helpers.entity_registry import ( from .const import ( _LOGGER, - CONF_FILTER_CORONA, + CONF_HEADLINE_FILTER, CONF_MESSAGE_SLOTS, CONF_REGIONS, CONST_REGION_MAPPING, CONST_REGIONS, DOMAIN, + NO_MATCH_REGEX, ) @@ -125,6 +126,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if group_input := user_input.get(group): user_input[CONF_REGIONS] += group_input + if not user_input[CONF_HEADLINE_FILTER]: + user_input[CONF_HEADLINE_FILTER] = NO_MATCH_REGEX + if user_input[CONF_REGIONS]: return self.async_create_entry( title="NINA", @@ -144,7 +148,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): vol.Required(CONF_MESSAGE_SLOTS, default=5): vol.All( int, vol.Range(min=1, max=20) ), - vol.Required(CONF_FILTER_CORONA, default=True): cv.boolean, + vol.Optional(CONF_HEADLINE_FILTER, default=""): cv.string, } ), errors=errors, @@ -255,10 +259,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_MESSAGE_SLOTS, default=self.data[CONF_MESSAGE_SLOTS], ): vol.All(int, vol.Range(min=1, max=20)), - vol.Required( - CONF_FILTER_CORONA, - default=self.data[CONF_FILTER_CORONA], - ): cv.boolean, + vol.Optional( + CONF_HEADLINE_FILTER, + default=self.data[CONF_HEADLINE_FILTER], + ): cv.string, } ), errors=errors, diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 8ba7c5ffaa6..36096d97dc1 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -11,9 +11,12 @@ SCAN_INTERVAL: timedelta = timedelta(minutes=5) DOMAIN: str = "nina" +NO_MATCH_REGEX: str = "/(?!)/" + CONF_REGIONS: str = "regions" CONF_MESSAGE_SLOTS: str = "slots" -CONF_FILTER_CORONA: str = "corona_filter" +CONF_FILTER_CORONA: str = "corona_filter" # deprecated +CONF_HEADLINE_FILTER: str = "headline_filter" ATTR_HEADLINE: str = "headline" ATTR_DESCRIPTION: str = "description" diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 6386a70d08b..98a088620ea 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["pynina==0.3.0"] + "requirements": ["PyNINA==0.3.0"] } diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index b22c2640084..23a1fb8dfa6 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -11,7 +11,7 @@ "_r_to_u": "City/county (R-U)", "_v_to_z": "City/county (V-Z)", "slots": "Maximum warnings per city/county", - "corona_filter": "Remove Corona Warnings" + "headline_filter": "Blacklist regex to filter warning headlines" } } }, @@ -36,7 +36,7 @@ "_r_to_u": "City/county (R-U)", "_v_to_z": "City/county (V-Z)", "slots": "Maximum warnings per city/county", - "corona_filter": "Remove Corona Warnings" + "headline_filter": "Blacklist regex to filter warning headlines" } } }, diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index d04e07f0214..4a3fc7cee96 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/norway_air", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["pyMetno==0.10.0"] + "requirements": ["PyMetno==0.10.0"] } diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index eae47b55179..e9e61527884 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -19,7 +19,6 @@ from .const import ( # noqa: F401 ATTR_TITLE, DOMAIN, NOTIFY_SERVICE_SCHEMA, - PERSISTENT_NOTIFICATION_SERVICE_SCHEMA, SERVICE_NOTIFY, SERVICE_PERSISTENT_NOTIFICATION, ) @@ -70,13 +69,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: title_tpl.hass = hass title = title_tpl.async_render(parse_result=False) - pn.async_create(hass, message.async_render(parse_result=False), title) + notification_id = None + if data := service.data.get(ATTR_DATA): + notification_id = data.get(pn.ATTR_NOTIFICATION_ID) + + pn.async_create( + hass, message.async_render(parse_result=False), title, notification_id + ) hass.services.async_register( DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, persistent_notification, - schema=PERSISTENT_NOTIFICATION_SERVICE_SCHEMA, + schema=NOTIFY_SERVICE_SCHEMA, ) return True diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py index d30702915d9..38dba680635 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -31,10 +31,3 @@ NOTIFY_SERVICE_SCHEMA = vol.Schema( vol.Optional(ATTR_DATA): dict, } ) - -PERSISTENT_NOTIFICATION_SERVICE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_MESSAGE): cv.template, - vol.Optional(ATTR_TITLE): cv.template, - } -) diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index 1a0de7344a3..9311acf2ba9 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -51,3 +51,11 @@ persistent_notification: example: "Your Garage Door Friend" selector: text: + data: + name: Data + description: + Extended information for notification. Optional depending on the + platform. + example: platform specific + selector: + object: diff --git a/homeassistant/components/nsw_fuel_station/__init__.py b/homeassistant/components/nsw_fuel_station/__init__.py index 6d45104e5d3..818656779a3 100644 --- a/homeassistant/components/nsw_fuel_station/__init__.py +++ b/homeassistant/components/nsw_fuel_station/__init__.py @@ -8,6 +8,7 @@ import logging from nsw_fuel import FuelCheckClient, FuelCheckError, Station from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,6 +19,8 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "nsw_fuel_station" SCAN_INTERVAL = datetime.timedelta(hours=1) +CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NSW Fuel Station platform.""" diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json index 02f7b985b3b..cea62996e6d 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json +++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_nsw_rfs_incidents"], - "requirements": ["aio_geojson_nsw_rfs_incidents==0.6"] + "requirements": ["aio-geojson-nsw-rfs-incidents==0.6"] } diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 2ad63c75e04..24c44b901a1 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -135,39 +135,6 @@ class NumberEntityDescription(EntityDescription): step: None = None unit_of_measurement: None = None # Type override, use native_unit_of_measurement - def __post_init__(self) -> None: - """Post initialisation processing.""" - if ( - self.max_value is not None - or self.min_value is not None - or self.step is not None - or self.unit_of_measurement is not None - ): - if ( # type: ignore[unreachable] - self.__class__.__name__ == "NumberEntityDescription" - ): - caller = inspect.stack()[2] - module = inspect.getmodule(caller[0]) - else: - module = inspect.getmodule(self) - 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" - ) - _LOGGER.warning( - ( - "%s is setting deprecated attributes on an instance of" - " NumberEntityDescription, this is not valid and will be" - " unsupported from Home Assistant 2022.10. Please %s" - ), - module.__name__ if module else self.__class__.__name__, - report_issue, - ) - self.native_unit_of_measurement = self.unit_of_measurement - def ceil_decimal(value: float, precision: float = 0) -> float: """Return the ceiling of f with d decimals. @@ -258,6 +225,13 @@ class NumberEntity(Entity): ATTR_MODE: self.mode, } + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For numbers this is True if the entity has a device class. + """ + return self.device_class is not None + @property def device_class(self) -> NumberDeviceClass | None: """Return the class of this entity.""" @@ -283,15 +257,6 @@ class NumberEntity(Entity): @final def min_value(self) -> float: """Return the minimum value.""" - if hasattr(self, "_attr_min_value"): - self._report_deprecated_number_entity() - return self._attr_min_value # type: ignore[return-value] - if ( - hasattr(self, "entity_description") - and self.entity_description.min_value is not None - ): - self._report_deprecated_number_entity() # type: ignore[unreachable] - return self.entity_description.min_value return self._convert_to_state_value(self.native_min_value, floor_decimal) @property @@ -310,15 +275,6 @@ class NumberEntity(Entity): @final def max_value(self) -> float: """Return the maximum value.""" - if hasattr(self, "_attr_max_value"): - self._report_deprecated_number_entity() - return self._attr_max_value # type: ignore[return-value] - if ( - hasattr(self, "entity_description") - and self.entity_description.max_value is not None - ): - self._report_deprecated_number_entity() # type: ignore[unreachable] - return self.entity_description.max_value return self._convert_to_state_value(self.native_max_value, ceil_decimal) @property @@ -335,15 +291,6 @@ class NumberEntity(Entity): @final def step(self) -> float: """Return the increment/decrement step.""" - if hasattr(self, "_attr_step"): - self._report_deprecated_number_entity() - return self._attr_step # type: ignore[return-value] - if ( - hasattr(self, "entity_description") - and self.entity_description.step is not None - ): - self._report_deprecated_number_entity() # type: ignore[unreachable] - return self.entity_description.step if hasattr(self, "_attr_native_step"): return self._attr_native_step if (native_step := self.native_step) is not None: @@ -389,17 +336,6 @@ class NumberEntity(Entity): if self._number_option_unit_of_measurement: return self._number_option_unit_of_measurement - if hasattr(self, "_attr_unit_of_measurement"): - self._report_deprecated_number_entity() - return self._attr_unit_of_measurement - if ( - hasattr(self, "entity_description") - and self.entity_description.unit_of_measurement is not None - ): - return ( # type: ignore[unreachable] - self.entity_description.unit_of_measurement - ) - native_unit_of_measurement = self.native_unit_of_measurement if ( @@ -420,10 +356,6 @@ class NumberEntity(Entity): @final def value(self) -> float | None: """Return the entity value to represent the entity state.""" - if hasattr(self, "_attr_value"): - self._report_deprecated_number_entity() - return self._attr_value - if (native_value := self.native_value) is None: return native_value return self._convert_to_state_value(native_value, round) @@ -450,7 +382,6 @@ class NumberEntity(Entity): self, value: float, method: Callable[[float, int], float] ) -> float: """Convert a value in the number's native unit to the configured unit.""" - native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement device_class = self.device_class @@ -480,7 +411,6 @@ class NumberEntity(Entity): def convert_to_native_value(self, value: float) -> float: """Convert a value to the number's native unit.""" - native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement device_class = self.device_class @@ -501,21 +431,6 @@ class NumberEntity(Entity): return value - def _report_deprecated_number_entity(self) -> None: - """Report that the number entity has not been upgraded.""" - if not self._deprecated_number_entity_reported: - self._deprecated_number_entity_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - ( - "Entity %s (%s) is using deprecated NumberEntity features which" - " will be unsupported from Home Assistant Core 2022.10, please %s" - ), - self.entity_id, - type(self), - report_issue, - ) - @callback def async_registry_entry_updated(self) -> None: """Run when the entity registry entry has been updated.""" diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index 971f8d5a514..22a51d85ad9 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -19,15 +20,22 @@ from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE ATYP_SET_VALUE = "set_value" -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( +_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): ATYP_SET_VALUE, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(ATTR_VALUE): vol.Coerce(float), } ) +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( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: @@ -44,7 +52,7 @@ async def async_get_actions( { CONF_DEVICE_ID: device_id, CONF_DOMAIN: DOMAIN, - CONF_ENTITY_ID: entry.entity_id, + CONF_ENTITY_ID: entry.id, CONF_TYPE: ATYP_SET_VALUE, } ) diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 82e42b67586..a3e16fbad76 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -6,11 +6,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from .connectivity import ObihaiConnection -from .const import LOGGER, PLATFORMS +from .const import DOMAIN, LOGGER, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + + requester = ObihaiConnection( + entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + await hass.async_add_executor_job(requester.update) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = requester await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -23,12 +31,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Migrating from version %s", version) if version != 2: - requester = ObihaiConnection( - entry.data[CONF_HOST], - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - ) - await hass.async_add_executor_job(requester.update) + requester: ObihaiConnection = hass.data[DOMAIN][entry.entry_id] device_mac = await hass.async_add_executor_job( requester.pyobihai.get_device_mac diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py index 0b84d40f4d2..d1b924b4693 100644 --- a/homeassistant/components/obihai/button.py +++ b/homeassistant/components/obihai/button.py @@ -2,21 +2,19 @@ from __future__ import annotations -from pyobihai import PyObihai - from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, EntityCategory +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from .connectivity import ObihaiConnection -from .const import OBIHAI +from .const import DOMAIN, OBIHAI BUTTON_DESCRIPTION = ButtonEntityDescription( key="reboot", @@ -32,13 +30,10 @@ async def async_setup_entry( async_add_entities: entity_platform.AddEntitiesCallback, ) -> None: """Set up the Obihai sensor entries.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - host = entry.data[CONF_HOST] - requester = ObihaiConnection(host, username, password) - await hass.async_add_executor_job(requester.update) - buttons = [ObihaiButton(requester.pyobihai, requester.serial)] + requester: ObihaiConnection = hass.data[DOMAIN][entry.entry_id] + + buttons = [ObihaiButton(requester)] async_add_entities(buttons, update_before_add=True) @@ -47,10 +42,11 @@ class ObihaiButton(ButtonEntity): entity_description = BUTTON_DESCRIPTION - def __init__(self, pyobihai: PyObihai, serial: str) -> None: + def __init__(self, requester: ObihaiConnection) -> None: """Initialize monitor sensor.""" - self._pyobihai = pyobihai - self._attr_unique_id = f"{serial}-reboot" + self.requester = requester + self._pyobihai = requester.pyobihai + self._attr_unique_id = f"{requester.serial}-reboot" def press(self) -> None: """Press button.""" diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py index 071390f1ad9..1ab3095a5a8 100644 --- a/homeassistant/components/obihai/connectivity.py +++ b/homeassistant/components/obihai/connectivity.py @@ -53,14 +53,15 @@ class ObihaiConnection: self.line_services: list = [] self.call_direction: list = [] self.pyobihai: PyObihai = None + self.available: bool = True def update(self) -> bool: """Validate connection and retrieve a list of sensors.""" if not self.pyobihai: - self.pyobihai = get_pyobihai(self.host, self.username, self.password) + self.pyobihai = validate_auth(self.host, self.username, self.password) - if not self.pyobihai.check_account(): + if not self.pyobihai: return False self.serial = self.pyobihai.get_device_serial() diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index 2907f3f179d..0f13bde5d6b 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/obihai", "iot_class": "local_polling", "loggers": ["pyobihai"], - "requirements": ["pyobihai==1.3.2"] + "requirements": ["pyobihai==1.4.2"] } diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 010b9780076..53208a1e6a1 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -1,20 +1,19 @@ """Support for Obihai Sensors.""" from __future__ import annotations -from datetime import timedelta +import datetime -from pyobihai import PyObihai +from requests.exceptions import RequestException from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .connectivity import ObihaiConnection -from .const import OBIHAI +from .const import DOMAIN, LOGGER, OBIHAI -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = datetime.timedelta(seconds=5) async def async_setup_entry( @@ -22,24 +21,18 @@ async def async_setup_entry( ) -> None: """Set up the Obihai sensor entries.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - host = entry.data[CONF_HOST] - requester = ObihaiConnection(host, username, password) + requester: ObihaiConnection = hass.data[DOMAIN][entry.entry_id] - await hass.async_add_executor_job(requester.update) sensors = [] for key in requester.services: - sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) + sensors.append(ObihaiServiceSensors(requester, key)) if requester.line_services is not None: for key in requester.line_services: - sensors.append( - ObihaiServiceSensors(requester.pyobihai, requester.serial, key) - ) + sensors.append(ObihaiServiceSensors(requester, key)) for key in requester.call_direction: - sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) + sensors.append(ObihaiServiceSensors(requester, key)) async_add_entities(sensors, update_before_add=True) @@ -47,18 +40,21 @@ async def async_setup_entry( class ObihaiServiceSensors(SensorEntity): """Get the status of each Obihai Lines.""" - def __init__(self, pyobihai: PyObihai, serial: str, service_name: str) -> None: + def __init__(self, requester: ObihaiConnection, service_name: str) -> None: """Initialize monitor sensor.""" + + self.requester = requester self._service_name = service_name self._attr_name = f"{OBIHAI} {self._service_name}" - self._pyobihai = pyobihai - self._attr_unique_id = f"{serial}-{self._service_name}" + self._pyobihai = requester.pyobihai + self._attr_unique_id = f"{requester.serial}-{self._service_name}" if self._service_name == "Last Reboot": self._attr_device_class = SensorDeviceClass.TIMESTAMP @property def icon(self) -> str: """Return an icon.""" + if self._service_name == "Call Direction": if self._attr_native_value == "No Active Calls": return "mdi:phone-off" @@ -87,26 +83,40 @@ class ObihaiServiceSensors(SensorEntity): def update(self) -> None: """Update the sensor.""" - if not self._pyobihai.check_account(): - self._attr_native_value = None - self._attr_available = False + + LOGGER.debug("Running update on %s", self._service_name) + try: + # port connection, and last caller info + if "Caller Info" in self._service_name or "Port" in self._service_name: + services = self._pyobihai.get_line_state() + + if services is not None and self._service_name in services: + self._attr_native_value = services.get(self._service_name) + elif self._service_name == "Call Direction": + call_direction = self._pyobihai.get_call_direction() + + if self._service_name in call_direction: + self._attr_native_value = call_direction.get(self._service_name) + else: # SIP Profile service sensors, phone sensor, and last reboot + services = self._pyobihai.get_state() + + if self._service_name in services: + self._attr_native_value = services.get(self._service_name) + + if not self.requester.available: + self.requester.available = True + LOGGER.info("Connection restored") + self._attr_available = True + return - services = self._pyobihai.get_state() + except RequestException as exc: + if self.requester.available: + LOGGER.warning("Connection failed, Obihai offline? %s", exc) + except IndexError as exc: + if self.requester.available: + LOGGER.warning("Connection failed, bad response: %s", exc) - if self._service_name in services: - self._attr_native_value = services.get(self._service_name) - - services = self._pyobihai.get_line_state() - - if services is not None and self._service_name in services: - self._attr_native_value = services.get(self._service_name) - - call_direction = self._pyobihai.get_call_direction() - - if self._service_name in call_direction: - self._attr_native_value = call_direction.get(self._service_name) - - if self._attr_native_value is None: - self._attr_available = False - self._attr_available = True + self._attr_native_value = None + self._attr_available = False + self.requester.available = False diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 51817be35b8..5b47394e0e4 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -193,7 +193,7 @@ class CoreConfigOnboardingView(_BaseOnboardingView): await self._async_mark_done(hass) # Integrations to set up when finishing onboarding - onboard_integrations = ["met", "radio_browser"] + onboard_integrations = ["google_translate", "met", "radio_browser"] # pylint: disable-next=import-outside-toplevel from homeassistant.components import hassio diff --git a/homeassistant/components/oncue/const.py b/homeassistant/components/oncue/const.py index bf248369987..7118944a4ec 100644 --- a/homeassistant/components/oncue/const.py +++ b/homeassistant/components/oncue/const.py @@ -3,10 +3,15 @@ import asyncio import aiohttp +from aiooncue import ServiceFailedException DOMAIN = "oncue" -CONNECTION_EXCEPTIONS = (asyncio.TimeoutError, aiohttp.ClientError) +CONNECTION_EXCEPTIONS = ( + asyncio.TimeoutError, + aiohttp.ClientError, + ServiceFailedException, +) CONNECTION_ESTABLISHED_KEY: str = "NetworkConnectionEstablished" diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json index 02c953736bb..24414e4efb8 100644 --- a/homeassistant/components/oncue/manifest.json +++ b/homeassistant/components/oncue/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/oncue", "iot_class": "cloud_polling", "loggers": ["aiooncue"], - "requirements": ["aiooncue==0.3.4"] + "requirements": ["aiooncue==0.3.5"] } diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 129cdf50979..5e226dcead7 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -70,8 +70,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="rssi", name="RSSI", + icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index 2d06b20a30a..b23abb54f8b 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -34,6 +34,7 @@ class OpenMeteoWeatherEntity( """Defines an Open-Meteo weather entity.""" _attr_has_entity_name = True + _attr_name = None _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR diff --git a/homeassistant/components/openhome/__init__.py b/homeassistant/components/openhome/__init__.py index 78294ceb6f4..c7ee5a7d00c 100644 --- a/homeassistant/components/openhome/__init__.py +++ b/homeassistant/components/openhome/__init__.py @@ -1 +1,55 @@ """The openhome component.""" + +import asyncio +import logging + +import aiohttp +from async_upnp_client.client import UpnpError +from openhomedevice.device import Device + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.UPDATE] + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Cleanup before removing config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> bool: + """Set up the configuration config entry.""" + _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) + + device = await hass.async_add_executor_job(Device, config_entry.data[CONF_HOST]) + + try: + await device.init() + except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError) as exc: + raise ConfigEntryNotReady from exc + + _LOGGER.debug("Initialised device: %s", device.uuid()) + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = device + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True diff --git a/homeassistant/components/openhome/config_flow.py b/homeassistant/components/openhome/config_flow.py new file mode 100644 index 00000000000..c8a13a3c7aa --- /dev/null +++ b/homeassistant/components/openhome/config_flow.py @@ -0,0 +1,66 @@ +"""Config flow for Linn / OpenHome.""" + +import logging +from typing import Any + +from homeassistant.components.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _is_complete_discovery(discovery_info: SsdpServiceInfo) -> bool: + """Test if discovery is complete and usable.""" + return bool(ATTR_UPNP_UDN in discovery_info.upnp and discovery_info.ssdp_location) + + +class OpenhomeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle an Openhome config flow.""" + + async def async_step_ssdp(self, discovery_info: SsdpServiceInfo) -> FlowResult: + """Handle a flow initialized by discovery.""" + _LOGGER.debug("async_step_ssdp: started") + + if not _is_complete_discovery(discovery_info): + _LOGGER.debug("async_step_ssdp: Incomplete discovery, ignoring") + return self.async_abort(reason="incomplete_discovery") + + _LOGGER.debug( + "async_step_ssdp: setting unique id %s", discovery_info.upnp[ATTR_UPNP_UDN] + ) + + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) + self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ssdp_location}) + + _LOGGER.debug( + "async_step_ssdp: create entry %s", discovery_info.upnp[ATTR_UPNP_UDN] + ) + + self.context[CONF_NAME] = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] + self.context[CONF_HOST] = discovery_info.ssdp_location + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user-confirmation of discovered node.""" + + if user_input is not None: + return self.async_create_entry( + title=self.context[CONF_NAME], + data={CONF_HOST: self.context[CONF_HOST]}, + ) + + return self.async_show_form( + step_id="confirm", + description_placeholders={CONF_NAME: self.context[CONF_NAME]}, + ) diff --git a/homeassistant/components/openhome/manifest.json b/homeassistant/components/openhome/manifest.json index aa563151f0b..de6c56a01dd 100644 --- a/homeassistant/components/openhome/manifest.json +++ b/homeassistant/components/openhome/manifest.json @@ -2,8 +2,23 @@ "domain": "openhome", "name": "Linn / OpenHome", "codeowners": ["@bazwilliams"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openhome", "iot_class": "local_polling", "loggers": ["async_upnp_client", "openhomedevice"], - "requirements": ["openhomedevice==2.0.2"] + "requirements": ["openhomedevice==2.2.0"], + "ssdp": [ + { + "st": "urn:av-openhome-org:service:Product:1" + }, + { + "st": "urn:av-openhome-org:service:Product:2" + }, + { + "st": "urn:av-openhome-org:service:Product:3" + }, + { + "st": "urn:av-openhome-org:service:Product:4" + } + ] } diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index b625d9976da..77ab0ac0aaf 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -9,7 +9,6 @@ from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp from async_upnp_client.client import UpnpError -from openhomedevice.device import Device import voluptuous as vol from homeassistant.components import media_source @@ -21,12 +20,13 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_PIN_INDEX, DATA_OPENHOME, SERVICE_INVOKE_PIN +from .const import ATTR_PIN_INDEX, DOMAIN, SERVICE_INVOKE_PIN _OpenhomeDeviceT = TypeVar("_OpenhomeDeviceT", bound="OpenhomeDevice") _R = TypeVar("_R") @@ -41,34 +41,20 @@ SUPPORT_OPENHOME = ( _LOGGER = logging.getLogger(__name__) -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 Openhome platform.""" + """Set up the Openhome config entry.""" - if not discovery_info: - return + _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) - openhome_data = hass.data.setdefault(DATA_OPENHOME, set()) - - name = discovery_info.get("name") - description = discovery_info.get("ssdp_description") - - _LOGGER.info("Openhome device found: %s", name) - device = await hass.async_add_executor_job(Device, description) - await device.init() - - # if device has already been discovered - if device.uuid() in openhome_data: - return + device = hass.data[DOMAIN][config_entry.entry_id] entity = OpenhomeDevice(hass, device) async_add_entities([entity]) - openhome_data.add(device.uuid()) platform = entity_platform.async_get_current_platform() @@ -133,6 +119,18 @@ class OpenhomeDevice(MediaPlayerEntity): self._attr_state = MediaPlayerState.PLAYING self._available = True + @property + def device_info(self): + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self._device.uuid()), + }, + manufacturer=self._device.manufacturer(), + model=self._device.model_name(), + name=self._device.friendly_name(), + ) + @property def available(self): """Device is available.""" diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py new file mode 100644 index 00000000000..54c2d16fb2b --- /dev/null +++ b/homeassistant/components/openhome/update.py @@ -0,0 +1,103 @@ +"""Update entities for Linn devices.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import aiohttp +from async_upnp_client.client import UpnpError + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up update entities for Reolink component.""" + + _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) + + device = hass.data[DOMAIN][config_entry.entry_id] + + entity = OpenhomeUpdateEntity(device) + + await entity.async_update() + + async_add_entities([entity]) + + +class OpenhomeUpdateEntity(UpdateEntity): + """Update entity for a Linn DS device.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, device): + """Initialize a Linn DS update entity.""" + self._device = device + self._attr_unique_id = f"{device.uuid()}-update" + + @property + def device_info(self): + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self._device.uuid()), + }, + manufacturer=self._device.manufacturer(), + model=self._device.model_name(), + name=self._device.friendly_name(), + ) + + async def async_update(self) -> None: + """Update state of entity.""" + + software_status = await self._device.software_status() + + if not software_status: + self._attr_installed_version = None + self._attr_latest_version = None + self._attr_release_summary = None + self._attr_release_url = None + return + + self._attr_installed_version = software_status["current_software"]["version"] + + if software_status["status"] == "update_available": + self._attr_latest_version = software_status["update_info"]["updates"][0][ + "version" + ] + self._attr_release_summary = software_status["update_info"]["updates"][0][ + "description" + ] + self._attr_release_url = software_status["update_info"]["releasenotesuri"] + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the latest firmware version.""" + try: + if self.latest_version: + await self._device.update_firmware() + except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError) as err: + raise HomeAssistantError( + f"Error updating {self._device.device.friendly_name}: {err}" + ) from err diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index cda86006bbd..6c6d3acb30e 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@joostlek"], "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.0.9"] + "requirements": ["python-opensky==0.0.10"] } diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 4af068f2b84..d53fbc136b2 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -35,6 +35,7 @@ ATTR_API_DEW_POINT = "dew_point" ATTR_API_WEATHER = "weather" ATTR_API_TEMPERATURE = "temperature" ATTR_API_FEELS_LIKE_TEMPERATURE = "feels_like_temperature" +ATTR_API_WIND_GUST = "wind_gust" ATTR_API_WIND_SPEED = "wind_speed" ATTR_API_WIND_BEARING = "wind_bearing" ATTR_API_HUMIDITY = "humidity" @@ -50,7 +51,10 @@ ATTR_API_FORECAST = "forecast" UPDATE_LISTENER = "update_listener" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] +ATTR_API_FORECAST_CLOUDS = "clouds" ATTR_API_FORECAST_CONDITION = "condition" +ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE = "feels_like_temperature" +ATTR_API_FORECAST_HUMIDITY = "humidity" ATTR_API_FORECAST_PRECIPITATION = "precipitation" ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" ATTR_API_FORECAST_PRESSURE = "pressure" diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index da29031d513..30f98bb39d1 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -4,7 +4,10 @@ from __future__ import annotations from typing import cast from homeassistant.components.weather import ( + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, @@ -29,9 +32,15 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ATTR_API_CLOUDS, ATTR_API_CONDITION, + ATTR_API_DEW_POINT, + ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_FORECAST, + ATTR_API_FORECAST_CLOUDS, ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE, + ATTR_API_FORECAST_HUMIDITY, ATTR_API_FORECAST_PRECIPITATION, ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, ATTR_API_FORECAST_PRESSURE, @@ -44,6 +53,7 @@ from .const import ( ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_GUST, ATTR_API_WIND_SPEED, ATTRIBUTION, DEFAULT_NAME, @@ -64,6 +74,9 @@ FORECAST_MAP = { ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_API_FORECAST_CLOUDS: ATTR_FORECAST_CLOUD_COVERAGE, + ATTR_API_FORECAST_HUMIDITY: ATTR_FORECAST_HUMIDITY, + ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: ATTR_FORECAST_NATIVE_APPARENT_TEMP, } @@ -116,6 +129,16 @@ class OpenWeatherMapWeather(WeatherEntity): """Return the current condition.""" return self._weather_coordinator.data[ATTR_API_CONDITION] + @property + def cloud_coverage(self) -> float | None: + """Return the Cloud coverage in %.""" + return self._weather_coordinator.data[ATTR_API_CLOUDS] + + @property + def native_apparent_temperature(self) -> float | None: + """Return the apparent temperature.""" + return self._weather_coordinator.data[ATTR_API_FEELS_LIKE_TEMPERATURE] + @property def native_temperature(self) -> float | None: """Return the temperature.""" @@ -131,6 +154,16 @@ class OpenWeatherMapWeather(WeatherEntity): """Return the humidity.""" return self._weather_coordinator.data[ATTR_API_HUMIDITY] + @property + def native_dew_point(self) -> float | None: + """Return the dew point.""" + return self._weather_coordinator.data[ATTR_API_DEW_POINT] + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed.""" + return self._weather_coordinator.data[ATTR_API_WIND_GUST] + @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 4602615769a..521c1f87ca2 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -21,7 +21,10 @@ from .const import ( ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_FORECAST, + ATTR_API_FORECAST_CLOUDS, ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE, + ATTR_API_FORECAST_HUMIDITY, ATTR_API_FORECAST_PRECIPITATION, ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, ATTR_API_FORECAST_PRESSURE, @@ -41,6 +44,7 @@ from .const import ( ATTR_API_WEATHER, ATTR_API_WEATHER_CODE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_GUST, ATTR_API_WIND_SPEED, CONDITION_CLASSES, DOMAIN, @@ -130,6 +134,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ATTR_API_PRESSURE: current_weather.pressure.get("press"), ATTR_API_HUMIDITY: current_weather.humidity, ATTR_API_WIND_BEARING: current_weather.wind().get("deg"), + ATTR_API_WIND_GUST: current_weather.wind().get("gust"), ATTR_API_WIND_SPEED: current_weather.wind().get("speed"), ATTR_API_CLOUDS: current_weather.clouds, ATTR_API_RAIN: self._get_rain(current_weather.rain), @@ -174,7 +179,11 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ATTR_API_FORECAST_CONDITION: self._get_condition( entry.weather_code, entry.reference_time("unix") ), - ATTR_API_CLOUDS: entry.clouds, + ATTR_API_FORECAST_CLOUDS: entry.clouds, + ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: entry.temperature("celsius").get( + "feels_like_day" + ), + ATTR_API_FORECAST_HUMIDITY: entry.humidity, } temperature_dict = entry.temperature("celsius") diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py index bd074cf5e5e..0111379df44 100644 --- a/homeassistant/components/opnsense/__init__.py +++ b/homeassistant/components/opnsense/__init__.py @@ -49,7 +49,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: tracker_interfaces = conf[CONF_TRACKER_INTERFACE] interfaces_client = diagnostics.InterfaceClient( - api_key, api_secret, url, verify_ssl + api_key, api_secret, url, verify_ssl, timeout=20 ) try: interfaces_client.get_arp() @@ -60,7 +60,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: if tracker_interfaces: # Verify that specified tracker interfaces are valid netinsight_client = diagnostics.NetworkInsightClient( - api_key, api_secret, url, verify_ssl + api_key, api_secret, url, verify_ssl, timeout=20 ) interfaces = list(netinsight_client.get_interfaces().values()) for interface in tracker_interfaces: diff --git a/homeassistant/components/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json index 89e8efa3426..bf8a41d1785 100644 --- a/homeassistant/components/opnsense/manifest.json +++ b/homeassistant/components/opnsense/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/opnsense", "iot_class": "local_polling", "loggers": ["pbr", "pyopnsense"], - "requirements": ["pyopnsense==0.2.0"] + "requirements": ["pyopnsense==0.4.0"] } diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index c8ab8246c8b..67c8412102d 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from contextlib import suppress import logging from typing import cast @@ -12,11 +13,18 @@ from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol import yarl -from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.components.hassio import ( + HassioAPIError, + HassioServiceInfo, + async_get_addon_info, +) +from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.components.thread import async_get_preferred_dataset from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN @@ -25,6 +33,32 @@ from .util import get_allowed_channel _LOGGER = logging.getLogger(__name__) +def _is_yellow(hass: HomeAssistant) -> bool: + """Return True if Home Assistant is running on a Home Assistant Yellow.""" + try: + yellow_hardware.async_info(hass) + except HomeAssistantError: + return False + return True + + +async def _title(hass: HomeAssistant, discovery_info: HassioServiceInfo) -> str: + """Return config entry title.""" + device: str | None = None + + with suppress(HassioAPIError): + addon_info = await async_get_addon_info(hass, discovery_info.slug) + device = addon_info.get("options", {}).get("device") + + if _is_yellow(hass) and device == "/dev/TTYAMA1": + return "Home Assistant Yellow" + + if device and "SkyConnect" in device: + return "Home Assistant SkyConnect" + + return discovery_info.name + + class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Open Thread Border Router.""" @@ -124,6 +158,6 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(discovery_info.uuid) return self.async_create_entry( - title="Open Thread Border Router", + title=await _title(self.hass, discovery_info), data=config_entry_data, ) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index d79d2fca686..5807ccecd74 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -45,7 +45,7 @@ OVERKIZ_TO_PRESET_MODE: dict[str, str] = { PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} # Map Overkiz HVAC modes to Home Assistant HVAC modes -OVERKIZ_TO_HVAC_MODE: dict[str, str] = { +OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.ON: HVACMode.HEAT, OverkizCommandParam.OFF: HVACMode.OFF, OverkizCommandParam.AUTO: HVACMode.AUTO, @@ -83,7 +83,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( ) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" states = self.device.states if (state := states[OverkizState.CORE_OPERATING_MODE]) and state.value_as_str: diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py index c8e4920a113..0c378d088c5 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -20,7 +20,7 @@ from ..entity import OverkizEntity PRESET_DRYING = "drying" -OVERKIZ_TO_HVAC_MODE: dict[str, str] = { +OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.EXTERNAL: HVACMode.HEAT, # manu OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog OverkizCommandParam.STANDBY: HVACMode.OFF, @@ -62,7 +62,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" if OverkizState.CORE_OPERATING_MODE in self.device.states: return OVERKIZ_TO_HVAC_MODE[ @@ -71,7 +71,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): return HVACMode.OFF - async def async_set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" await self.executor.async_execute_command( OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index b6835d93ebb..7722269a48b 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -21,7 +21,7 @@ from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity -OVERKIZ_TO_HVAC_MODE: dict[str, str] = { +OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.AUTO: HVACMode.AUTO, OverkizCommandParam.ECO: HVACMode.AUTO, OverkizCommandParam.MANU: HVACMode.HEAT, @@ -101,7 +101,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): return None @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return OVERKIZ_TO_HVAC_MODE[ cast(str, self.executor.select_state(OverkizState.IO_PASS_APC_HEATING_MODE)) @@ -135,7 +135,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): OverkizCommand.REFRESH_PASS_APC_HEATING_PROFILE ) - async def async_set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" await self.async_set_heating_mode(HVAC_MODE_TO_OVERKIZ[hvac_mode]) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py index 33c1f0c4a2a..74f7637b997 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -8,7 +8,7 @@ from homeassistant.const import UnitOfTemperature from ..entity import OverkizEntity -OVERKIZ_TO_HVAC_MODE: dict[str, str] = { +OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.HEATING: HVACMode.HEAT, OverkizCommandParam.DRYING: HVACMode.DRY, OverkizCommandParam.COOLING: HVACMode.COOL, @@ -25,7 +25,7 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return OVERKIZ_TO_HVAC_MODE[ cast( @@ -33,7 +33,7 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): ) ] - async def async_set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" await self.executor.async_execute_command( OverkizCommand.SET_PASS_APC_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index aaae64e0454..7409b5307cf 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -74,7 +74,7 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): ) @property - def hvac_mode(self) -> str: + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return OVERKIZ_TO_HVAC_MODES[ cast( diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py index fdaf0d61f1f..3d883738de2 100644 --- a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -73,7 +73,7 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): ) @property - def hvac_action(self) -> str: + def hvac_action(self) -> HVACAction: """Return the current running hvac operation.""" return OVERKIZ_TO_HVAC_ACTION[ cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE)) diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index 6306a11acf0..16ea12a5d96 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -18,6 +18,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): """Representation of an Overkiz device entity.""" _attr_has_entity_name = True + _attr_name: str | None = None def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator @@ -118,3 +119,5 @@ class OverkizDescriptiveEntity(OverkizEntity): # In case of sub device, use the provided label # and append the name of the type of entity self._attr_name = f"{self.device.label} {description.name}" + elif isinstance(description.name, str): + self._attr_name = description.name diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index b03b60cd753..d88996c7e02 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.8.0"], + "requirements": ["pyoverkiz==1.9.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py index b82a8727388..13b2051ffa2 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -34,8 +34,8 @@ class OwnTracksFlow(config_entries.ConfigFlow, domain=DOMAIN): if supports_encryption(): secret_desc = ( - f"The encryption key is {secret} (on Android under preferences ->" - " advanced)" + f"The encryption key is {secret} (on Android under Preferences >" + " Advanced)" ) else: secret_desc = "Encryption is not supported because nacl is not installed." diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json index a127d9d6a4a..2486e01223f 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: Private 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: `''`\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." } } } diff --git a/homeassistant/components/panasonic_viera/manifest.json b/homeassistant/components/panasonic_viera/manifest.json index d626ae2bf9e..2afa6599cb2 100644 --- a/homeassistant/components/panasonic_viera/manifest.json +++ b/homeassistant/components/panasonic_viera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/panasonic_viera", "iot_class": "local_polling", "loggers": ["panasonic_viera"], - "requirements": ["panasonic_viera==0.3.6"] + "requirements": ["panasonic-viera==0.3.6"] } diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index fe8849c7788..581720c2730 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -1,7 +1,7 @@ """Support for displaying persistent notifications.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from datetime import datetime import logging from typing import Any, Final, TypedDict @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, singleton from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -63,6 +63,17 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +@callback +def async_register_callback( + hass: HomeAssistant, + _callback: Callable[[UpdateType, dict[str, Notification]], None], +) -> CALLBACK_TYPE: + """Register a callback.""" + return async_dispatcher_connect( + hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, _callback + ) + + @bind_hass def create( hass: HomeAssistant, @@ -129,6 +140,20 @@ def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: ) +@callback +def async_dismiss_all(hass: HomeAssistant) -> None: + """Remove all notifications.""" + notifications = _async_get_or_create_notifications(hass) + notifications_copy = notifications.copy() + notifications.clear() + async_dispatcher_send( + hass, + SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, + UpdateType.REMOVED, + notifications_copy, + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the persistent notification component.""" @@ -147,6 +172,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Handle the dismiss notification service call.""" async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID]) + @callback + def dismiss_all_service(call: ServiceCall) -> None: + """Handle the dismiss all notification service call.""" + async_dismiss_all(hass) + hass.services.async_register( DOMAIN, "create", @@ -164,6 +194,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, "dismiss", dismiss_service, SCHEMA_SERVICE_NOTIFICATION ) + hass.services.async_register(DOMAIN, "dismiss_all", dismiss_all_service, None) + websocket_api.async_register_command(hass, websocket_get_notifications) websocket_api.async_register_command(hass, websocket_subscribe_notifications) diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml index 60dbf5c864a..046ea237560 100644 --- a/homeassistant/components/persistent_notification/services.yaml +++ b/homeassistant/components/persistent_notification/services.yaml @@ -4,14 +4,14 @@ create: fields: message: name: Message - description: Message body of the notification. [Templates accepted] + description: Message body of the notification. required: true example: Please check your configuration.yaml. selector: text: title: name: Title - description: Optional title for your notification. [Templates accepted] + description: Optional title for your notification. example: Test notification selector: text: @@ -33,3 +33,7 @@ dismiss: example: 1234 selector: text: + +dismiss_all: + name: Dismiss All + description: Remove all notifications. diff --git a/homeassistant/components/persistent_notification/trigger.py b/homeassistant/components/persistent_notification/trigger.py new file mode 100644 index 00000000000..4c9c2bd9204 --- /dev/null +++ b/homeassistant/components/persistent_notification/trigger.py @@ -0,0 +1,80 @@ +"""Offer persistent_notifications triggered automation rules.""" +from __future__ import annotations + +import logging +from typing import Final + +import voluptuous as vol + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import Notification, UpdateType, async_register_callback + +_LOGGER = logging.getLogger(__name__) + + +CONF_NOTIFICATION_ID: Final = "notification_id" +CONF_UPDATE_TYPE: Final = "update_type" + +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): "persistent_notification", + vol.Optional(CONF_NOTIFICATION_ID): str, + vol.Optional(CONF_UPDATE_TYPE): vol.All( + cv.ensure_list, [vol.Coerce(UpdateType)] + ), + } +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + trigger_data: TriggerData = trigger_info["trigger_data"] + job = HassJob(action) + + persistent_notification_id = config.get(CONF_NOTIFICATION_ID) + update_types = config.get(CONF_UPDATE_TYPE) + + @callback + def persistent_notification_listener( + update_type: UpdateType, notifications: dict[str, Notification] + ) -> None: + """Listen for persistent_notification updates.""" + + for notification in notifications.values(): + if update_types and update_type not in update_types: + continue + if ( + persistent_notification_id + and notification[CONF_NOTIFICATION_ID] != persistent_notification_id + ): + continue + + hass.async_run_hass_job( + job, + { + "trigger": { + **trigger_data, + "platform": "persistent_notification", + "update_type": update_type, + "notification": notification, + } + }, + ) + + _LOGGER.debug( + "Attaching persistent_notification trigger for ID: '%s', update_types: %s", + persistent_notification_id, + update_types, + ) + + return async_register_callback(hass, persistent_notification_listener) diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 705acefa60f..46b1340a28d 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/philips_js", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.0.0"] + "requirements": ["ha-philipsjs==3.1.0"] } diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index c6ca70bdc84..bdd55bb2dad 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -70,6 +70,7 @@ class PhilipsTVMediaPlayer( _attr_device_class = MediaPlayerDeviceClass.TV _attr_has_entity_name = True + _attr_name = None def __init__( self, diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index ff91b5259b2..f0e0d93231c 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -50,7 +50,7 @@ "name": "Status of last order" }, "last_order_max_order_time": { - "name": "Max order time of last slot" + "name": "Max order time of last order" }, "last_order_delivery_time": { "name": "Last order delivery time" diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index b78702de54d..3ff36f2e283 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -6,6 +6,7 @@ import logging from icmplib import SocketPermissionError, ping as icmp_ping from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType @@ -13,6 +14,8 @@ from .const import DOMAIN, PING_PRIVS, PLATFORMS _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ping integration.""" diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index c8b4ce5a204..786012d466c 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -6,14 +6,14 @@ from contextlib import suppress from datetime import timedelta import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any import async_timeout from icmplib import NameLookupError, async_ping import voluptuous as vol from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, ) @@ -53,7 +53,7 @@ PING_MATCHER_BUSYBOX = re.compile( WIN32_PING_MATCHER = re.compile(r"(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms") -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME): cv.string, @@ -89,27 +89,14 @@ async def async_setup_platform( class PingBinarySensor(RestoreEntity, BinarySensorEntity): """Representation of a Ping Binary sensor.""" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + def __init__(self, name: str, ping: PingDataSubProcess | PingDataICMPLib) -> None: """Initialize the Ping Binary sensor.""" - self._available = False - self._name = name + self._attr_available = False + self._attr_name = name self._ping = ping - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def available(self) -> bool: - """Return if we have done the first ping.""" - return self._available - - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.CONNECTIVITY - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" @@ -130,7 +117,7 @@ class PingBinarySensor(RestoreEntity, BinarySensorEntity): async def async_update(self) -> None: """Get the latest data.""" await self._ping.async_update() - self._available = True + self._attr_available = True async def async_added_to_hass(self) -> None: """Restore previous state on restart to avoid blocking startup.""" @@ -138,7 +125,7 @@ class PingBinarySensor(RestoreEntity, BinarySensorEntity): last_state = await self.async_get_last_state() if last_state is not None: - self._available = True + self._attr_available = True if last_state is None or last_state.state != STATE_ON: self._ping.data = None @@ -221,7 +208,7 @@ class PingDataSubProcess(PingData): self._ip_address, ] - async def async_ping(self): + 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, @@ -249,7 +236,7 @@ class PingDataSubProcess(PingData): out_error, ) - if pinger.returncode > 1: + if pinger.returncode and pinger.returncode > 1: # returncode of 1 means the host is unreachable _LOGGER.exception( "Error running command: `%s`, return code: %s", @@ -261,9 +248,13 @@ class PingDataSubProcess(PingData): 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: @@ -274,7 +265,7 @@ class PingDataSubProcess(PingData): ) if pinger: with suppress(TypeError): - await pinger.kill() + await pinger.kill() # type: ignore[func-returns-value] del pinger return None diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 68111df89ea..f546bd6bacc 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -2,14 +2,13 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import datetime, timedelta import logging import subprocess from icmplib import async_multiping import voluptuous as vol -from homeassistant import util from homeassistant.components.device_tracker import ( CONF_SCAN_INTERVAL, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, @@ -22,6 +21,7 @@ from homeassistant.core import HomeAssistant 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.process import kill_subprocess @@ -44,7 +44,14 @@ PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( class HostSubProcess: """Host object with ping detection.""" - def __init__(self, ip_address, dev_id, hass, config, privileged): + def __init__( + self, + ip_address: str, + dev_id: str, + hass: HomeAssistant, + config: ConfigType, + privileged: bool | None, + ) -> None: """Initialize the Host pinger.""" self.hass = hass self.ip_address = ip_address @@ -52,7 +59,7 @@ class HostSubProcess: self._count = config[CONF_PING_COUNT] self._ping_cmd = ["ping", "-n", "-q", "-c1", "-W1", ip_address] - def ping(self): + def ping(self) -> bool | None: """Send an ICMP echo request and return True if success.""" with subprocess.Popen( self._ping_cmd, @@ -108,7 +115,7 @@ async def async_setup_scanner( for (dev_id, ip) in config[CONF_HOSTS].items() ] - async def async_update(now): + async def async_update(now: datetime) -> None: """Update all the hosts on every interval time.""" results = await gather_with_concurrency( CONCURRENT_PING_LIMIT, @@ -124,7 +131,7 @@ async def async_setup_scanner( else: - async def async_update(now): + async def async_update(now: datetime) -> None: """Update all the hosts on every interval time.""" responses = await async_multiping( list(ip_to_dev_id), @@ -141,14 +148,14 @@ async def async_setup_scanner( ) ) - async def _async_update_interval(now): + async def _async_update_interval(now: datetime) -> None: try: await async_update(now) finally: if not hass.is_stopping: async_track_point_in_utc_time( - hass, _async_update_interval, util.dt.utcnow() + interval + hass, _async_update_interval, now + interval ) - await _async_update_interval(None) + await _async_update_interval(dt_util.now()) return True diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 59ae14b8ca9..4ce5a359dcd 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -18,7 +18,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import ( @@ -54,6 +58,8 @@ from .view import PlexImageView _LOGGER = logging.getLogger(__package__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + def is_plex_media_id(media_content_id): """Return whether the media_content_id is a valid Plex media_id.""" diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 4c4ed8d8d0a..bc0c54c49bf 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "plexapi==4.13.2", + "PlexAPI==4.13.2", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index be572679605..6585c011c2d 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate, ParamSpec, TypeVar, cast import plexapi.exceptions import requests.exceptions @@ -535,7 +535,10 @@ class PlexMediaPlayer(MediaPlayerEntity): identifiers={(DOMAIN, self.machine_identifier)}, manufacturer=self.device_platform or "Plex", model=self.device_product or self.device_make, - name=self.name, + # Instead of setting the device name to the entity name, plex + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), sw_version=self.device_version, via_device=(DOMAIN, self.plex_server.machine_identifier), ) diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 9684c79792a..1c3c944c9c4 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -199,7 +199,8 @@ class PlexServer: if _update_plexdirect_hostname(): config_entry_update_needed = True else: - raise Unauthorized( # pylint: disable=raise-missing-from + # pylint: disable-next=raise-missing-from + raise Unauthorized( # noqa: TRY200 "New certificate cannot be validated" " with provided token" ) diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 62576471448..10d005d1043 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -145,7 +145,7 @@ def process_plex_payload( plex_server = get_plex_server(hass, plex_server_id=server_id) else: # Handle legacy payloads without server_id in URL host position - if plex_url.host == "search": + if plex_url.host == "search": # noqa: PLR5501 content = {} else: content = int(plex_url.host) # type: ignore[arg-type] diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index f7941d1f02d..36626c2324e 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -42,6 +42,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): """Representation of an Plugwise thermostat.""" _attr_has_entity_name = True + _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index d708fe741c2..7a504a0db84 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -343,6 +343,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="modulation_level", diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index bce25c07b17..d1d27b78769 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -1,7 +1,7 @@ """Automation manager for boards manufactured by ProgettiHWSW Italy.""" -from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI from ProgettiHWSW.input import Input +from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI from ProgettiHWSW.relay import Relay from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index 058d76cdf05..b2019389fe3 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -2,8 +2,8 @@ from datetime import timedelta import logging -from ProgettiHWSW.input import Input import async_timeout +from ProgettiHWSW.input import Input from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/progettihwsw/manifest.json b/homeassistant/components/progettihwsw/manifest.json index e22abd6dd4a..6cad66e1360 100644 --- a/homeassistant/components/progettihwsw/manifest.json +++ b/homeassistant/components/progettihwsw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/progettihwsw", "iot_class": "local_polling", "loggers": ["ProgettiHWSW"], - "requirements": ["progettihwsw==0.1.1"] + "requirements": ["ProgettiHWSW==0.1.1"] } diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 956848a6594..dc7f838bcbc 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -3,8 +3,8 @@ from datetime import timedelta import logging from typing import Any -from ProgettiHWSW.relay import Relay import async_timeout +from ProgettiHWSW.relay import Relay from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/prometheus/manifest.json b/homeassistant/components/prometheus/manifest.json index dbbe8a1c9fc..8ec332c1daf 100644 --- a/homeassistant/components/prometheus/manifest.json +++ b/homeassistant/components/prometheus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prometheus", "iot_class": "assumed_state", "loggers": ["prometheus_client"], - "requirements": ["prometheus_client==0.7.1"] + "requirements": ["prometheus-client==0.7.1"] } diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 0567c551d98..a4520435161 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -110,6 +110,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class Proximity(Entity): """Representation of a Proximity.""" + # This entity is legacy and does not have a platform. + # We can't fix this easily without breaking changes. + _no_platform_reported = True + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 7ebaa6e53dd..88a2a6c9b0f 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["pillow==9.5.0"] + "requirements": ["Pillow==9.5.0"] } diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 4f93fd3407e..1ee4274e5bb 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -47,6 +47,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { "printer": ( PrusaLinkSensorEntityDescription[PrinterInfo]( key="printer.state", + name=None, icon="mdi:printer-3d", value_fn=lambda data: ( "pausing" diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 0f5c57c5e4c..1c87a275126 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -53,6 +53,8 @@ PS4_COMMAND_SCHEMA = vol.Schema( PLATFORMS = [Platform.MEDIA_PLAYER] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + class PS4Data: """Init Data Class.""" diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 23438dd80c4..42bc15cf0ca 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -192,12 +192,10 @@ class PS4Device(MediaPlayerEntity): self.async_get_title_data(title_id, name), "ps4.media_player-get_title_data", ) - else: - if self.state != MediaPlayerState.IDLE: - self.idle() - else: - if self.state != MediaPlayerState.STANDBY: - self.state_standby() + elif self.state != MediaPlayerState.IDLE: + self.idle() + elif self.state != MediaPlayerState.STANDBY: + self.state_standby() elif self._retry > DEFAULT_RETRIES: self.state_unknown() diff --git a/homeassistant/components/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py index bed0e94ccd9..14d90d4ca0b 100644 --- a/homeassistant/components/pushbullet/__init__.py +++ b/homeassistant/components/pushbullet/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .api import PushBulletNotificationProvider @@ -24,6 +24,8 @@ PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the pushbullet component.""" diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py index 551e374fbb6..c3b15b7c130 100644 --- a/homeassistant/components/pushover/__init__.py +++ b/homeassistant/components/pushover/__init__.py @@ -7,13 +7,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import CONF_USER_KEY, DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the pushover component.""" diff --git a/homeassistant/components/pushover/manifest.json b/homeassistant/components/pushover/manifest.json index d086321c088..3b538f756e0 100644 --- a/homeassistant/components/pushover/manifest.json +++ b/homeassistant/components/pushover/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pushover", "iot_class": "cloud_push", "loggers": ["pushover_complete"], - "requirements": ["pushover_complete==1.1.1"] + "requirements": ["pushover-complete==1.1.1"] } diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index bbb262ac7db..10751d28c06 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -221,7 +221,7 @@ def execute(hass, filename, source, data=None): try: _LOGGER.info("Executing %s: %s", filename, data) # pylint: disable-next=exec-used - exec(compiled.code, restricted_globals) + exec(compiled.code, restricted_globals) # noqa: S102 except ScriptError as err: logger.error("Error executing script: %s", err) except Exception as err: # pylint: disable=broad-except diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index eb6cfe236e0..63aa2f2f916 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["restrictedpython==6.0"] + "requirements": ["RestrictedPython==6.0"] } diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 5154ae155ec..53e8d4b9660 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -35,11 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_VERIFY_SSL], ) except LoginRequired as err: - _LOGGER.error("Invalid credentials") - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady("Invalid credentials") from err except RequestException as err: - _LOGGER.error("Failed to connect") - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady("Failed to connect") from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index c56bb8102b8..e2c1526e4f8 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], - "requirements": ["python-qbittorrent==0.4.2"] + "requirements": ["python-qbittorrent==0.4.3"] } diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 6b758daab0a..15a634cf7a9 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -86,7 +86,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.6.0", + breaks_in_ha_version="2023.11.0", is_fixable=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_yaml", diff --git a/homeassistant/components/qld_bushfire/manifest.json b/homeassistant/components/qld_bushfire/manifest.json index e21371d96af..5e7d9948309 100644 --- a/homeassistant/components/qld_bushfire/manifest.json +++ b/homeassistant/components/qld_bushfire/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["georss_qld_bushfire_alert_client"], - "requirements": ["georss_qld_bushfire_alert_client==0.5"] + "requirements": ["georss-qld-bushfire-alert-client==0.5"] } diff --git a/homeassistant/components/qnap/__init__.py b/homeassistant/components/qnap/__init__.py index 534096628df..2491e69803f 100644 --- a/homeassistant/components/qnap/__init__.py +++ b/homeassistant/components/qnap/__init__.py @@ -1 +1,33 @@ """The qnap component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import QnapCoordinator + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set the config entry up.""" + hass.data.setdefault(DOMAIN, {}) + coordinator = QnapCoordinator(hass, config_entry) + # Fetch initial data so we have data when entities subscribe + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][config_entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + hass.data[DOMAIN].pop(config_entry.entry_id) + return unload_ok diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py new file mode 100644 index 00000000000..689fe30a870 --- /dev/null +++ b/homeassistant/components/qnap/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow to configure qnap component.""" +from __future__ import annotations + +import logging +from typing import Any + +from qnapstats import QNAPStats +from requests.exceptions import ConnectTimeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import ( + CONF_DRIVES, + CONF_NICS, + CONF_VOLUMES, + DEFAULT_PORT, + DEFAULT_SSL, + DEFAULT_TIMEOUT, + DEFAULT_VERIFY_SSL, + DOMAIN, +) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class QnapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Qnap configuration flow.""" + + VERSION = 1 + + async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: + """Set the config entry up from yaml.""" + import_info.pop(CONF_MONITORED_CONDITIONS, None) + import_info.pop(CONF_NICS, None) + import_info.pop(CONF_DRIVES, None) + import_info.pop(CONF_VOLUMES, None) + return await self.async_step_user(import_info) + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + host = user_input[CONF_HOST] + protocol = "https" if user_input[CONF_SSL] else "http" + api = QNAPStats( + host=f"{protocol}://{host}", + port=user_input[CONF_PORT], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + verify_ssl=user_input[CONF_VERIFY_SSL], + timeout=DEFAULT_TIMEOUT, + ) + try: + stats = await self.hass.async_add_executor_job(api.get_system_stats) + except ConnectTimeout: + errors["base"] = "cannot_connect" + except TypeError: + errors["base"] = "invalid_auth" + except Exception as error: # pylint: disable=broad-except + _LOGGER.error(error) + errors["base"] = "unknown" + else: + unique_id = stats["system"]["serial_number"] + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + title = stats["system"]["name"] + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), + errors=errors, + ) diff --git a/homeassistant/components/qnap/const.py b/homeassistant/components/qnap/const.py new file mode 100644 index 00000000000..d1bbb64cc47 --- /dev/null +++ b/homeassistant/components/qnap/const.py @@ -0,0 +1,12 @@ +"""The Qnap constants.""" + +CONF_DRIVES = "drives" +CONF_NICS = "nics" +CONF_VOLUMES = "volumes" + +DEFAULT_PORT = 8080 +DEFAULT_TIMEOUT = 5 +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + +DOMAIN = "qnap" diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py new file mode 100644 index 00000000000..b868a931ebd --- /dev/null +++ b/homeassistant/components/qnap/coordinator.py @@ -0,0 +1,59 @@ +"""Data coordinator for the qnap integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from qnapstats import QNAPStats + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_TIMEOUT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +UPDATE_INTERVAL = timedelta(minutes=1) + +_LOGGER = logging.getLogger(__name__) + + +class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Custom coordinator for the qnap integration.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the qnap coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + + protocol = "https" if config_entry.data[CONF_SSL] else "http" + self._api = QNAPStats( + f"{protocol}://{config_entry.data.get(CONF_HOST)}", + config_entry.data.get(CONF_PORT), + config_entry.data.get(CONF_USERNAME), + config_entry.data.get(CONF_PASSWORD), + verify_ssl=config_entry.data.get(CONF_VERIFY_SSL), + timeout=config_entry.data.get(CONF_TIMEOUT), + ) + + def _sync_update(self) -> dict[str, dict[str, Any]]: + """Get the latest data from the Qnap API.""" + return { + "system_stats": self._api.get_system_stats(), + "system_health": self._api.get_system_health(), + "smart_drive_health": self._api.get_smart_disk_health(), + "volumes": self._api.get_volumes(), + "bandwidth": self._api.get_bandwidth(), + } + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Get the latest data from the Qnap API.""" + return await self.hass.async_add_executor_job(self._sync_update) diff --git a/homeassistant/components/qnap/manifest.json b/homeassistant/components/qnap/manifest.json index 95ab9264dfc..608d57a7cc4 100644 --- a/homeassistant/components/qnap/manifest.json +++ b/homeassistant/components/qnap/manifest.json @@ -1,8 +1,10 @@ { "domain": "qnap", "name": "QNAP", - "codeowners": [], + "codeowners": ["@disforw"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qnap", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["qnapstats"], "requirements": ["qnapstats==0.4.0"] diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 66fc5631718..6d214b63e2e 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -1,17 +1,17 @@ """Support for QNAP NAS Sensors.""" from __future__ import annotations -from datetime import timedelta import logging -from qnapstats import QNAPStats import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( ATTR_NAME, @@ -31,14 +31,25 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_DRIVES, + CONF_NICS, + CONF_VOLUMES, + DEFAULT_PORT, + DEFAULT_TIMEOUT, + DOMAIN, +) +from .coordinator import QnapCoordinator _LOGGER = logging.getLogger(__name__) ATTR_DRIVE = "Drive" -ATTR_DRIVE_SIZE = "Drive Size" ATTR_IP = "IP Address" ATTR_MAC = "MAC Address" ATTR_MASK = "Mask" @@ -53,18 +64,6 @@ ATTR_TYPE = "Type" ATTR_UPTIME = "Uptime" ATTR_VOLUME_SIZE = "Volume Size" -CONF_DRIVES = "drives" -CONF_NICS = "nics" -CONF_VOLUMES = "volumes" -DEFAULT_NAME = "QNAP" -DEFAULT_PORT = 8080 -DEFAULT_TIMEOUT = 5 - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) - -NOTIFICATION_ID = "qnap_notification" -NOTIFICATION_TITLE = "QNAP Sensor Setup" - _SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="status", @@ -76,6 +75,8 @@ _SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( name="System Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + state_class=SensorStateClass.MEASUREMENT, ), ) _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -84,12 +85,16 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( name="CPU Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:checkbox-marked-circle-outline", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="cpu_usage", name="CPU Usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", + state_class=SensorStateClass.MEASUREMENT, ), ) _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -99,6 +104,8 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="memory_used", @@ -106,12 +113,15 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="memory_percent_used", name="Memory Usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), ) _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -126,6 +136,8 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.MEBIBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="network_rx", @@ -133,6 +145,8 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.MEBIBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), ) _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -140,12 +154,16 @@ _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( key="drive_smart_status", name="SMART Status", icon="mdi:checkbox-marked-circle-outline", + entity_registry_enabled_default=False, ), SensorEntityDescription( key="drive_temp", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), ) _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -155,6 +173,8 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="volume_size_free", @@ -162,12 +182,15 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="volume_percentage_used", name="Volume Used", native_unit_of_measurement=PERCENTAGE, icon="mdi:chart-pie", + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -202,77 +225,90 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the QNAP NAS sensor.""" - api = QNAPStatsAPI(config) - api.update() + """Set up the qnap sensor platform from yaml.""" - # QNAP is not available - if not api.data: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.12.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry.""" + coordinator = QnapCoordinator(hass, config_entry) + await coordinator.async_refresh() + if not coordinator.last_update_success: raise PlatformNotReady - - monitored_conditions = config[CONF_MONITORED_CONDITIONS] + uid = config_entry.unique_id + assert uid is not None sensors: list[QNAPSensor] = [] - # Basic sensors sensors.extend( [ - QNAPSystemSensor(api, description) + QNAPSystemSensor(coordinator, description, uid) for description in _SYSTEM_MON_COND - if description.key in monitored_conditions ] ) + sensors.extend( - [ - QNAPCPUSensor(api, description) - for description in _CPU_MON_COND - if description.key in monitored_conditions - ] + [QNAPCPUSensor(coordinator, description, uid) for description in _CPU_MON_COND] ) + sensors.extend( [ - QNAPMemorySensor(api, description) + QNAPMemorySensor(coordinator, description, uid) for description in _MEMORY_MON_COND - if description.key in monitored_conditions ] ) # Network sensors sensors.extend( [ - QNAPNetworkSensor(api, description, nic) - for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]) + QNAPNetworkSensor(coordinator, description, uid, nic) + for nic in coordinator.data["system_stats"]["nics"] for description in _NETWORK_MON_COND - if description.key in monitored_conditions ] ) # Drive sensors sensors.extend( [ - QNAPDriveSensor(api, description, drive) - for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]) + QNAPDriveSensor(coordinator, description, uid, drive) + for drive in coordinator.data["smart_drive_health"] for description in _DRIVE_MON_COND - if description.key in monitored_conditions ] ) # Volume sensors sensors.extend( [ - QNAPVolumeSensor(api, description, volume) - for volume in config.get(CONF_VOLUMES, api.data["volumes"]) + QNAPVolumeSensor(coordinator, description, uid, volume) + for volume in coordinator.data["volumes"] for description in _VOLUME_MON_COND - if description.key in monitored_conditions ] ) - - add_entities(sensors) + async_add_entities(sensors) def round_nicely(number): @@ -285,62 +321,38 @@ def round_nicely(number): return round(number) -class QNAPStatsAPI: - """Class to interface with the API.""" - - def __init__(self, config): - """Initialize the API wrapper.""" - - protocol = "https" if config[CONF_SSL] else "http" - self._api = QNAPStats( - f"{protocol}://{config.get(CONF_HOST)}", - config.get(CONF_PORT), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - verify_ssl=config.get(CONF_VERIFY_SSL), - timeout=config.get(CONF_TIMEOUT), - ) - - self.data = {} - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update API information and store locally.""" - try: - self.data["system_stats"] = self._api.get_system_stats() - self.data["system_health"] = self._api.get_system_health() - self.data["smart_drive_health"] = self._api.get_smart_disk_health() - self.data["volumes"] = self._api.get_volumes() - self.data["bandwidth"] = self._api.get_bandwidth() - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Failed to fetch QNAP stats from the NAS") - - -class QNAPSensor(SensorEntity): +class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): """Base class for a QNAP sensor.""" def __init__( - self, api, description: SensorEntityDescription, monitor_device=None + self, + coordinator: QnapCoordinator, + description: SensorEntityDescription, + unique_id: str, + monitor_device: str | None = None, ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = description + self.device_name = self.coordinator.data["system_stats"]["system"]["name"] self.monitor_device = monitor_device - self._api = api + self._attr_unique_id = f"{unique_id}_{description.key}" + if monitor_device: + self._attr_unique_id = f"{self._attr_unique_id}_{monitor_device}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=self.device_name, + model=self.coordinator.data["system_stats"]["system"]["model"], + sw_version=self.coordinator.data["system_stats"]["firmware"]["version"], + manufacturer="QNAP", + ) @property def name(self): """Return the name of the sensor, if any.""" - server_name = self._api.data["system_stats"]["system"]["name"] - if self.monitor_device is not None: - return ( - f"{server_name} {self.entity_description.name} ({self.monitor_device})" - ) - return f"{server_name} {self.entity_description.name}" - - def update(self) -> None: - """Get the latest data for the states.""" - self._api.update() + return f"{self.device_name} {self.entity_description.name} ({self.monitor_device})" + return f"{self.device_name} {self.entity_description.name}" class QNAPCPUSensor(QNAPSensor): @@ -350,9 +362,9 @@ class QNAPCPUSensor(QNAPSensor): def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == "cpu_temp": - return self._api.data["system_stats"]["cpu"]["temp_c"] + return self.coordinator.data["system_stats"]["cpu"]["temp_c"] if self.entity_description.key == "cpu_usage": - return self._api.data["system_stats"]["cpu"]["usage_percent"] + return self.coordinator.data["system_stats"]["cpu"]["usage_percent"] class QNAPMemorySensor(QNAPSensor): @@ -361,11 +373,11 @@ class QNAPMemorySensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - free = float(self._api.data["system_stats"]["memory"]["free"]) / 1024 + free = float(self.coordinator.data["system_stats"]["memory"]["free"]) / 1024 if self.entity_description.key == "memory_free": return round_nicely(free) - total = float(self._api.data["system_stats"]["memory"]["total"]) / 1024 + total = float(self.coordinator.data["system_stats"]["memory"]["total"]) / 1024 used = total - free if self.entity_description.key == "memory_used": @@ -377,8 +389,8 @@ class QNAPMemorySensor(QNAPSensor): @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["system_stats"]["memory"] + if self.coordinator.data: + data = self.coordinator.data["system_stats"]["memory"] size = round_nicely(float(data["total"]) / 1024) return {ATTR_MEMORY_SIZE: f"{size} {UnitOfInformation.GIBIBYTES}"} @@ -390,10 +402,10 @@ class QNAPNetworkSensor(QNAPSensor): def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == "network_link_status": - nic = self._api.data["system_stats"]["nics"][self.monitor_device] + nic = self.coordinator.data["system_stats"]["nics"][self.monitor_device] return nic["link_status"] - data = self._api.data["bandwidth"][self.monitor_device] + data = self.coordinator.data["bandwidth"][self.monitor_device] if self.entity_description.key == "network_tx": return round_nicely(data["tx"] / 1024 / 1024) @@ -403,8 +415,8 @@ class QNAPNetworkSensor(QNAPSensor): @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["system_stats"]["nics"][self.monitor_device] + if self.coordinator.data: + data = self.coordinator.data["system_stats"]["nics"][self.monitor_device] return { ATTR_IP: data["ip"], ATTR_MASK: data["mask"], @@ -423,16 +435,16 @@ class QNAPSystemSensor(QNAPSensor): def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == "status": - return self._api.data["system_health"] + return self.coordinator.data["system_health"] if self.entity_description.key == "system_temp": - return int(self._api.data["system_stats"]["system"]["temp_c"]) + return int(self.coordinator.data["system_stats"]["system"]["temp_c"]) @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["system_stats"] + if self.coordinator.data: + data = self.coordinator.data["system_stats"] days = int(data["uptime"]["days"]) hours = int(data["uptime"]["hours"]) minutes = int(data["uptime"]["minutes"]) @@ -451,7 +463,7 @@ class QNAPDriveSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - data = self._api.data["smart_drive_health"][self.monitor_device] + data = self.coordinator.data["smart_drive_health"][self.monitor_device] if self.entity_description.key == "drive_smart_status": return data["health"] @@ -462,7 +474,7 @@ class QNAPDriveSensor(QNAPSensor): @property def name(self): """Return the name of the sensor, if any.""" - server_name = self._api.data["system_stats"]["system"]["name"] + server_name = self.coordinator.data["system_stats"]["system"]["name"] return ( f"{server_name} {self.entity_description.name} (Drive" @@ -472,8 +484,8 @@ class QNAPDriveSensor(QNAPSensor): @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["smart_drive_health"][self.monitor_device] + if self.coordinator.data: + data = self.coordinator.data["smart_drive_health"][self.monitor_device] return { ATTR_DRIVE: data["drive_number"], ATTR_MODEL: data["model"], @@ -488,7 +500,7 @@ class QNAPVolumeSensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - data = self._api.data["volumes"][self.monitor_device] + data = self.coordinator.data["volumes"][self.monitor_device] free_gb = int(data["free_size"]) / 1024 / 1024 / 1024 if self.entity_description.key == "volume_size_free": @@ -506,12 +518,10 @@ class QNAPVolumeSensor(QNAPSensor): @property def extra_state_attributes(self): """Return the state attributes.""" - if self._api.data: - data = self._api.data["volumes"][self.monitor_device] + if self.coordinator.data: + data = self.coordinator.data["volumes"][self.monitor_device] total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 return { - ATTR_VOLUME_SIZE: ( - f"{round_nicely(total_gb)} {UnitOfInformation.GIBIBYTES}" - ) + ATTR_VOLUME_SIZE: f"{round_nicely(total_gb)} {UnitOfInformation.GIBIBYTES}" } diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json new file mode 100644 index 00000000000..26ca5dedd34 --- /dev/null +++ b/homeassistant/components/qnap/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to the QNAP device", + "description": "This qnap sensor allows getting various statistics from your QNAP NAS.", + "data": { + "host": "Hostname", + "username": "Username", + "password": "Password", + "port": "Port", + "ssl": "Enable SSL", + "verify_ssl": "Verify SSL" + } + } + }, + "error": { + "cannot_connect": "Cannot connect to host", + "invalid_auth": "Bad authentication", + "unknown": "Unknown error" + } + } +} diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 787255187cc..a19760ad989 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["pillow==9.5.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==9.5.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/rachio/manifest.json b/homeassistant/components/rachio/manifest.json index 14582134e84..e58341633b1 100644 --- a/homeassistant/components/rachio/manifest.json +++ b/homeassistant/components/rachio/manifest.json @@ -25,7 +25,7 @@ }, "iot_class": "cloud_push", "loggers": ["rachiopy"], - "requirements": ["rachiopy==1.0.3"], + "requirements": ["RachioPy==1.0.3"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/radarr/binary_sensor.py b/homeassistant/components/radarr/binary_sensor.py index 5339588c5fa..5d439680bc2 100644 --- a/homeassistant/components/radarr/binary_sensor.py +++ b/homeassistant/components/radarr/binary_sensor.py @@ -18,7 +18,7 @@ from .const import DOMAIN, HEALTH_ISSUES BINARY_SENSOR_TYPE = BinarySensorEntityDescription( key="health", - name="Health", + translation_key="health", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, ) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 0ed64ce3035..367e302d56f 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -18,8 +18,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import RadarrEntity from .const import DOMAIN @@ -78,7 +76,7 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { ), "movie": RadarrSensorEntityDescription[int]( key="movies", - name="Movies", + translation_key="movies", native_unit_of_measurement="Movies", icon="mdi:television", entity_registry_enabled_default=False, @@ -86,7 +84,7 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { ), "status": RadarrSensorEntityDescription[SystemStatus]( key="start_time", - name="Start time", + translation_key="start_time", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -104,24 +102,6 @@ BYTE_SIZES = [ PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Radarr platform.""" - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index 299dd0a56b0..5cd7bcfc449 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -35,10 +35,19 @@ } } }, - "issues": { - "removed_yaml": { - "title": "The Radarr YAML configuration has been removed", - "description": "Configuring Radarr using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "entity": { + "binary_sensor": { + "health": { + "name": "Health" + } + }, + "sensor": { + "movies": { + "name": "Movies" + }, + "start_time": { + "name": "Start time" + } } } } diff --git a/homeassistant/components/radio_browser/__init__.py b/homeassistant/components/radio_browser/__init__.py index d93d7c48823..fdd7537e9e1 100644 --- a/homeassistant/components/radio_browser/__init__.py +++ b/homeassistant/components/radio_browser/__init__.py @@ -1,6 +1,7 @@ """The Radio Browser integration.""" from __future__ import annotations +from aiodns.error import DNSError from radios import RadioBrowser, RadioBrowserError from homeassistant.config_entries import ConfigEntry @@ -23,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await radios.stats() - except RadioBrowserError as err: + except (DNSError, RadioBrowserError) as err: raise ConfigEntryNotReady("Could not connect to Radio Browser API") from err hass.data[DOMAIN] = radios diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 38f3c03fb03..e915c52c9dc 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -67,6 +67,7 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) self._attr_name = imported_name self._attr_has_entity_name = False else: + self._attr_name = None self._attr_has_entity_name = True self._state = None self._duration_minutes = duration_minutes diff --git a/homeassistant/components/recollect_waste/calendar.py b/homeassistant/components/recollect_waste/calendar.py index 120ab77c3b3..c439f647da5 100644 --- a/homeassistant/components/recollect_waste/calendar.py +++ b/homeassistant/components/recollect_waste/calendar.py @@ -48,6 +48,7 @@ class ReCollectWasteCalendar(ReCollectWasteEntity, CalendarEntity): """Define a ReCollect Waste calendar.""" _attr_icon = "mdi:delete-empty" + _attr_name = None def __init__( self, diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 74b17d9daa7..64ce1aa7d55 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -434,11 +434,11 @@ def _state_changed_during_period_stmt( ) else: stmt += lambda q: q.order_by(States.entity_id, States.last_updated.desc()) + elif schema_version >= 31: + stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts) else: - if schema_version >= 31: - stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts) - else: - stmt += lambda q: q.order_by(States.entity_id, States.last_updated) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated) + if limit: stmt += lambda q: q.limit(limit) return stmt diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 33c6a516c65..2e868542457 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "sqlalchemy==2.0.15", + "SQLAlchemy==2.0.15", "fnv-hash-fast==0.3.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index b8436da97d5..33d8c7b5e67 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1303,7 +1303,7 @@ def _migrate_statistics_columns_to_timestamp( with session_scope(session=session_maker()) as session: session.connection().execute( text( - f"UPDATE {table} set start_ts=strftime('%s',start) + " + f"UPDATE {table} set start_ts=strftime('%s',start) + " # noqa: S608 "cast(substr(start,-7) AS FLOAT), " f"created_ts=strftime('%s',created) + " "cast(substr(created,-7) AS FLOAT), " @@ -1321,7 +1321,7 @@ def _migrate_statistics_columns_to_timestamp( with session_scope(session=session_maker()) as session: result = session.connection().execute( text( - f"UPDATE {table} set start_ts=" + f"UPDATE {table} set start_ts=" # noqa: S608 "IF(start is NULL or UNIX_TIMESTAMP(start) is NULL,0," "UNIX_TIMESTAMP(start) " "), " @@ -1343,7 +1343,7 @@ def _migrate_statistics_columns_to_timestamp( with session_scope(session=session_maker()) as session: result = session.connection().execute( text( - f"UPDATE {table} set start_ts=" # nosec + f"UPDATE {table} set start_ts=" # noqa: S608 "(case when start is NULL then 0 else EXTRACT(EPOCH FROM start::timestamptz) end), " "created_ts=EXTRACT(EPOCH FROM created::timestamptz), " "last_reset_ts=EXTRACT(EPOCH FROM last_reset::timestamptz) " diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 09b113f03eb..46f140305e3 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -92,7 +92,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] exclude_integrations={"recorder"}, error_if_core=False, ) - return super(NullPool, self)._create_connection() + return NullPool._create_connection(self) class MutexPool(StaticPool): diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ee9662a2157..9bbf35bb40a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2400,7 +2400,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: session.connection().execute( text( - f"update {table} set start = NULL, created = NULL, last_reset = NULL;" + f"update {table} set start = NULL, created = NULL, last_reset = NULL;" # noqa: S608 ) ) elif engine.dialect.name == SupportedDialect.MYSQL: @@ -2410,7 +2410,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: session.connection() .execute( text( - f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;" + f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;" # noqa: S608 ) ) .rowcount @@ -2425,7 +2425,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: session.connection() .execute( text( - f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # nosec + f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # noqa: S608 f"where id in (select id from {table} where start is not NULL LIMIT 100000)" ) ) diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index 4e08719e572..85266a37939 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -10,11 +10,11 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from . import BaseLRUTableManager 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 +from . import BaseLRUTableManager if TYPE_CHECKING: from ..core import Recorder diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index d5541c547d5..fd03bdd14d2 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -9,12 +9,12 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event -from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids from ..tasks import RefreshEventTypesTask from ..util import chunked, execute_stmt_lambda_element +from . import BaseLRUTableManager if TYPE_CHECKING: from ..core import Recorder diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 442277be96e..3ae67b932bf 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -11,11 +11,11 @@ from homeassistant.core import Event from homeassistant.helpers.entity import entity_sources from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from . import BaseLRUTableManager 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 +from . import BaseLRUTableManager if TYPE_CHECKING: from ..core import Recorder diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index bc4a8cfd2d9..b8f6204d318 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -8,11 +8,11 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event -from . import BaseLRUTableManager 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 +from . import BaseLRUTableManager if TYPE_CHECKING: from ..core import Recorder diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 1c50fd0a77c..f3de9824a16 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -278,9 +278,11 @@ def basic_sanity_check(cursor: SQLiteCursor) -> bool: for table in TABLES_TO_CHECK: if table in (TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES): - cursor.execute(f"SELECT * FROM {table};") # nosec # not injection + cursor.execute(f"SELECT * FROM {table};") # noqa: S608 # not injection else: - cursor.execute(f"SELECT * FROM {table} LIMIT 1;") # nosec # not injection + cursor.execute( + f"SELECT * FROM {table} LIMIT 1;" # noqa: S608 # not injection + ) return True @@ -528,11 +530,10 @@ def setup_connection_for_dialect( version, ) - else: - if not version or version < MIN_VERSION_MYSQL: - _fail_unsupported_version( - version or version_string, "MySQL", MIN_VERSION_MYSQL - ) + elif not version or version < MIN_VERSION_MYSQL: + _fail_unsupported_version( + version or version_string, "MySQL", MIN_VERSION_MYSQL + ) slow_range_in_select = bool( not version diff --git a/homeassistant/components/remote/device_action.py b/homeassistant/components/remote/device_action.py index 09c540b5e01..936c7aca37a 100644 --- a/homeassistant/components/remote/device_action.py +++ b/homeassistant/components/remote/device_action.py @@ -3,7 +3,10 @@ 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 @@ -12,7 +15,14 @@ from . import DOMAIN # mypy: disallow-any-generics -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_call_action_from_config( diff --git a/homeassistant/components/remote_rpi_gpio/__init__.py b/homeassistant/components/remote_rpi_gpio/__init__.py index c77ae7fde9c..1654cc0c01d 100644 --- a/homeassistant/components/remote_rpi_gpio/__init__.py +++ b/homeassistant/components/remote_rpi_gpio/__init__.py @@ -2,9 +2,6 @@ from gpiozero import LED, DigitalInputDevice from gpiozero.pins.pigpio import PiGPIOFactory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType - CONF_BOUNCETIME = "bouncetime" CONF_INVERT_LOGIC = "invert_logic" CONF_PULL_MODE = "pull_mode" @@ -16,11 +13,6 @@ DEFAULT_PULL_MODE = "UP" DOMAIN = "remote_rpi_gpio" -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Raspberry Pi Remote GPIO component.""" - return True - - def setup_output(address, port, invert_logic): """Set up a GPIO as output.""" diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index 37994830c4d..bc0e694e8eb 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -11,6 +11,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .. import remote_rpi_gpio from . import ( CONF_BOUNCETIME, CONF_INVERT_LOGIC, @@ -19,7 +20,6 @@ from . import ( DEFAULT_INVERT_LOGIC, DEFAULT_PULL_MODE, ) -from .. import remote_rpi_gpio CONF_PORTS = "ports" diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index 862efb0f89d..962cf6b4f3c 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -12,8 +12,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_INVERT_LOGIC, DEFAULT_INVERT_LOGIC from .. import remote_rpi_gpio +from . import CONF_INVERT_LOGIC, DEFAULT_INVERT_LOGIC CONF_PORTS = "ports" diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py new file mode 100644 index 00000000000..211f7c88e40 --- /dev/null +++ b/homeassistant/components/renson/__init__.py @@ -0,0 +1,88 @@ +"""The Renson integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +import async_timeout +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [ + Platform.SENSOR, +] + + +@dataclass +class RensonData: + """Renson data class.""" + + api: RensonVentilation + coordinator: RensonCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Renson from a config entry.""" + + api = RensonVentilation(entry.data[CONF_HOST]) + coordinator = RensonCoordinator("Renson", hass, api) + + if not await hass.async_add_executor_job(api.connect): + raise ConfigEntryNotReady("Cannot connect to Renson device") + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RensonData( + api, + 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 + + +class RensonCoordinator(DataUpdateCoordinator): + """Data update coordinator for Renson.""" + + def __init__( + self, + name: str, + hass: HomeAssistant, + api: RensonVentilation, + update_interval=timedelta(seconds=30), + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=name, + # Polling interval. Will only be polled if there are subscribers. + update_interval=update_interval, + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from API endpoint.""" + async with async_timeout.timeout(30): + return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/config_flow.py b/homeassistant/components/renson/config_flow.py new file mode 100644 index 00000000000..9883772ce02 --- /dev/null +++ b/homeassistant/components/renson/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for Renson integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from renson_endura_delta import renson +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Renson.""" + + VERSION = 1 + + async def validate_input( + self, hass: HomeAssistant, data: dict[str, Any] + ) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + api = renson.RensonVentilation(data[CONF_HOST]) + + if not await self.hass.async_add_executor_job(api.connect): + raise CannotConnect + + return {"title": "Renson"} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await self.validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/renson/const.py b/homeassistant/components/renson/const.py new file mode 100644 index 00000000000..840e1ce428a --- /dev/null +++ b/homeassistant/components/renson/const.py @@ -0,0 +1,3 @@ +"""Constants for the Renson integration.""" + +DOMAIN = "renson" diff --git a/homeassistant/components/renson/entity.py b/homeassistant/components/renson/entity.py new file mode 100644 index 00000000000..526077d2d7f --- /dev/null +++ b/homeassistant/components/renson/entity.py @@ -0,0 +1,47 @@ +"""Entity class for Renson ventilation unit.""" +from __future__ import annotations + +from renson_endura_delta.field_enum import ( + DEVICE_NAME_FIELD, + FIRMWARE_VERSION_FIELD, + HARDWARE_VERSION_FIELD, + MAC_ADDRESS, +) +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import RensonCoordinator +from .const import DOMAIN + + +class RensonEntity(CoordinatorEntity[RensonCoordinator]): + """Renson entity.""" + + def __init__( + self, name: str, api: RensonVentilation, coordinator: RensonCoordinator + ) -> None: + """Initialize the Renson entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, api.get_field_value(coordinator.data, MAC_ADDRESS.name)) + }, + manufacturer="Renson", + model=api.get_field_value(coordinator.data, DEVICE_NAME_FIELD.name), + name="Ventilation", + sw_version=api.get_field_value( + coordinator.data, FIRMWARE_VERSION_FIELD.name + ), + hw_version=api.get_field_value( + coordinator.data, HARDWARE_VERSION_FIELD.name + ), + ) + + self.api = api + + self._attr_unique_id = ( + api.get_field_value(coordinator.data, MAC_ADDRESS.name) + f"{name}" + ) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json new file mode 100644 index 00000000000..5ff219cc26c --- /dev/null +++ b/homeassistant/components/renson/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "renson", + "name": "Renson", + "codeowners": ["@jimmyd-be"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/renson", + "iot_class": "local_polling", + "requirements": ["renson-endura-delta==1.5.0"] +} diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py new file mode 100644 index 00000000000..9817951b094 --- /dev/null +++ b/homeassistant/components/renson/sensor.py @@ -0,0 +1,314 @@ +"""Sensor data of the Renson ventilation unit.""" +from __future__ import annotations + +from dataclasses import dataclass + +from renson_endura_delta.field_enum import ( + AIR_QUALITY_FIELD, + BREEZE_LEVEL_FIELD, + BREEZE_TEMPERATURE_FIELD, + BYPASS_LEVEL_FIELD, + BYPASS_TEMPERATURE_FIELD, + CO2_FIELD, + CO2_HYSTERESIS_FIELD, + CO2_QUALITY_FIELD, + CO2_THRESHOLD_FIELD, + CURRENT_AIRFLOW_EXTRACT_FIELD, + CURRENT_AIRFLOW_INGOING_FIELD, + CURRENT_LEVEL_FIELD, + DAY_POLLUTION_FIELD, + DAYTIME_FIELD, + FILTER_REMAIN_FIELD, + HUMIDITY_FIELD, + INDOOR_TEMP_FIELD, + MANUAL_LEVEL_FIELD, + NIGHT_POLLUTION_FIELD, + NIGHTTIME_FIELD, + OUTDOOR_TEMP_FIELD, + FieldEnum, +) +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfTemperature, + UnitOfTime, + UnitOfVolumeFlowRate, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator, RensonData +from .const import DOMAIN +from .entity import RensonEntity + + +@dataclass +class RensonSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + field: FieldEnum + raw_format: bool + + +@dataclass +class RensonSensorEntityDescription( + SensorEntityDescription, RensonSensorEntityDescriptionMixin +): + """Description of sensor.""" + + +SENSORS: tuple[RensonSensorEntityDescription, ...] = ( + RensonSensorEntityDescription( + key="CO2_QUALITY_FIELD", + name="CO2 quality category", + field=CO2_QUALITY_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["good", "bad", "poor"], + ), + RensonSensorEntityDescription( + key="AIR_QUALITY_FIELD", + name="Air quality category", + field=AIR_QUALITY_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["good", "bad", "poor"], + ), + RensonSensorEntityDescription( + key="CO2_FIELD", + name="CO2 quality", + field=CO2_FIELD, + raw_format=True, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + RensonSensorEntityDescription( + key="AIR_FIELD", + name="Air quality", + field=AIR_QUALITY_FIELD, + state_class=SensorStateClass.MEASUREMENT, + raw_format=True, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + RensonSensorEntityDescription( + key="CURRENT_LEVEL_FIELD", + name="Ventilation level", + field=CURRENT_LEVEL_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze", "Holiday"], + ), + RensonSensorEntityDescription( + key="CURRENT_AIRFLOW_EXTRACT_FIELD", + name="Total airflow out", + field=CURRENT_AIRFLOW_EXTRACT_FIELD, + raw_format=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + RensonSensorEntityDescription( + key="CURRENT_AIRFLOW_INGOING_FIELD", + name="Total airflow in", + field=CURRENT_AIRFLOW_INGOING_FIELD, + raw_format=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + RensonSensorEntityDescription( + key="OUTDOOR_TEMP_FIELD", + name="Outdoor air temperature", + field=OUTDOOR_TEMP_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + RensonSensorEntityDescription( + key="INDOOR_TEMP_FIELD", + name="Extract air temperature", + field=INDOOR_TEMP_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + RensonSensorEntityDescription( + key="FILTER_REMAIN_FIELD", + name="Filter change", + field=FILTER_REMAIN_FIELD, + raw_format=False, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.DAYS, + ), + RensonSensorEntityDescription( + key="HUMIDITY_FIELD", + name="Relative humidity", + field=HUMIDITY_FIELD, + raw_format=False, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + RensonSensorEntityDescription( + key="MANUAL_LEVEL_FIELD", + name="Manual level", + field=MANUAL_LEVEL_FIELD, + raw_format=False, + device_class=SensorDeviceClass.ENUM, + options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze", "Holiday"], + ), + RensonSensorEntityDescription( + key="BREEZE_TEMPERATURE_FIELD", + name="Breeze temperature", + field=BREEZE_TEMPERATURE_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="BREEZE_LEVEL_FIELD", + name="Breeze level", + field=BREEZE_LEVEL_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze"], + ), + RensonSensorEntityDescription( + key="DAYTIME_FIELD", + name="Start day time", + field=DAYTIME_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="NIGHTTIME_FIELD", + name="Start night time", + field=NIGHTTIME_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="DAY_POLLUTION_FIELD", + name="Day pollution level", + field=DAY_POLLUTION_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "Level1", + "Level2", + "Level3", + "Level4", + ], + ), + RensonSensorEntityDescription( + key="NIGHT_POLLUTION_FIELD", + name="Night pollution level", + field=NIGHT_POLLUTION_FIELD, + raw_format=False, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "Level1", + "Level2", + "Level3", + "Level4", + ], + ), + RensonSensorEntityDescription( + key="CO2_THRESHOLD_FIELD", + name="CO2 threshold", + field=CO2_THRESHOLD_FIELD, + raw_format=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="CO2_HYSTERESIS_FIELD", + name="CO2 hysteresis", + field=CO2_HYSTERESIS_FIELD, + raw_format=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + entity_registry_enabled_default=False, + ), + RensonSensorEntityDescription( + key="BYPASS_TEMPERATURE_FIELD", + name="Bypass activation temperature", + field=BYPASS_TEMPERATURE_FIELD, + raw_format=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + RensonSensorEntityDescription( + key="BYPASS_LEVEL_FIELD", + name="Bypass level", + field=BYPASS_LEVEL_FIELD, + raw_format=False, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +class RensonSensor(RensonEntity, SensorEntity): + """Get a sensor data from the Renson API and store it in the state of the class.""" + + def __init__( + self, + description: RensonSensorEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__(description.key, api, coordinator) + + self.field = description.field + self.entity_description = description + + self.data_type = description.field.field_type + self.raw_format = description.raw_format + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + all_data = self.coordinator.data + + value = self.api.get_field_value(all_data, self.field.name) + + if self.raw_format: + self._attr_native_value = value + else: + self._attr_native_value = self.api.parse_value(value, self.data_type) + + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson sensor platform.""" + + data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + RensonSensor(description, data.api, data.coordinator) for description in SENSORS + ] + + async_add_entities(entities) diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json new file mode 100644 index 00000000000..16c5de158a9 --- /dev/null +++ b/homeassistant/components/renson/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index a5050d3c436..923df261d84 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -8,7 +8,6 @@ from datetime import timedelta import logging from typing import Literal -from aiohttp import ClientConnectorError import async_timeout from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError @@ -58,8 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await host.stop() raise ConfigEntryAuthFailed(err) from err except ( - ClientConnectorError, - asyncio.TimeoutError, ReolinkException, ReolinkError, ) as err: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index df5bf968ae1..75ad26665c3 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -79,6 +79,10 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._username = entry_data[CONF_USERNAME] self._password = entry_data[CONF_PASSWORD] self._reauth = True + self.context["title_placeholders"]["ip_address"] = entry_data[CONF_HOST] + self.context["title_placeholders"]["hostname"] = self.context[ + "title_placeholders" + ]["name"] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index c57f7b1e77e..81fbda63fef 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -9,6 +9,7 @@ from typing import Any import aiohttp from aiohttp.web import Request from reolink_aio.api import Host +from reolink_aio.enums import SubType from reolink_aio.exceptions import ReolinkError, SubscriptionError from homeassistant.components import webhook @@ -24,9 +25,11 @@ from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 60 -FIRST_ONVIF_TIMEOUT = 15 +FIRST_ONVIF_TIMEOUT = 10 SUBSCRIPTION_RENEW_THRESHOLD = 300 POLL_INTERVAL_NO_PUSH = 5 +LONG_POLL_COOLDOWN = 0.75 +LONG_POLL_ERROR_COOLDOWN = 30 _LOGGER = logging.getLogger(__name__) @@ -59,10 +62,14 @@ class ReolinkHost: self.webhook_id: str | None = None self._base_url: str = "" self._webhook_url: str = "" - self._webhook_reachable: asyncio.Event = asyncio.Event() + self._webhook_reachable: bool = False + self._long_poll_received: bool = False + self._long_poll_error: bool = False self._cancel_poll: CALLBACK_TYPE | None = None self._cancel_onvif_check: CALLBACK_TYPE | None = None + self._cancel_long_poll_check: CALLBACK_TYPE | None = None self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) + self._long_poll_task: asyncio.Task | None = None self._lost_subscription: bool = False @property @@ -184,15 +191,32 @@ class ReolinkHost: async def _async_check_onvif(self, *_) -> None: """Check the ONVIF subscription.""" - if ( - self._api.supported(None, "initial_ONVIF_state") - and not self._webhook_reachable.is_set() - ): + if self._webhook_reachable: + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + self._cancel_onvif_check = None + return + if self._api.supported(None, "initial_ONVIF_state"): _LOGGER.debug( "Did not receive initial ONVIF state on webhook '%s' after %i seconds", self._webhook_url, FIRST_ONVIF_TIMEOUT, ) + + # ONVIF push is not received, start long polling and schedule check + await self._async_start_long_polling() + self._cancel_long_poll_check = async_call_later( + self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif_long_poll + ) + + self._cancel_onvif_check = None + + async def _async_check_onvif_long_poll(self, *_) -> None: + """Check if ONVIF long polling is working.""" + if not self._long_poll_received: + _LOGGER.debug( + "Did not receive state through ONVIF long polling after %i seconds", + FIRST_ONVIF_TIMEOUT, + ) ir.async_create_issue( self._hass, DOMAIN, @@ -209,10 +233,10 @@ class ReolinkHost: else: ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") - # If no ONVIF push is received, start fast polling + # If no ONVIF push or long polling state is received, start fast polling await self._async_poll_all_motion() - self._cancel_onvif_check = None + self._cancel_long_poll_check = None async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" @@ -222,11 +246,7 @@ class ReolinkHost: """Disconnect from the API, so the connection will be released.""" try: await self._api.unsubscribe() - except ( - aiohttp.ClientConnectorError, - asyncio.TimeoutError, - ReolinkError, - ) as err: + except ReolinkError as err: _LOGGER.error( "Reolink error while unsubscribing from host %s:%s: %s", self._api.host, @@ -236,11 +256,7 @@ class ReolinkHost: try: await self._api.logout() - except ( - aiohttp.ClientConnectorError, - asyncio.TimeoutError, - ReolinkError, - ) as err: + except ReolinkError as err: _LOGGER.error( "Reolink error while logging out for host %s:%s: %s", self._api.host, @@ -248,6 +264,20 @@ class ReolinkHost: str(err), ) + async def _async_start_long_polling(self): + """Start ONVIF long polling task.""" + if self._long_poll_task is None: + await self._api.subscribe(sub_type=SubType.long_poll) + self._long_poll_task = asyncio.create_task(self._async_long_polling()) + + async def _async_stop_long_polling(self): + """Stop ONVIF long polling task.""" + if self._long_poll_task is not None: + self._long_poll_task.cancel() + self._long_poll_task = None + + await self._api.unsubscribe(sub_type=SubType.long_poll) + async def stop(self, event=None): """Disconnect the API.""" if self._cancel_poll is not None: @@ -256,6 +286,10 @@ class ReolinkHost: if self._cancel_onvif_check is not None: self._cancel_onvif_check() self._cancel_onvif_check = None + if self._cancel_long_poll_check is not None: + self._cancel_long_poll_check() + self._cancel_long_poll_check = None + await self._async_stop_long_polling() self.unregister_webhook() await self.disconnect() @@ -264,7 +298,7 @@ class ReolinkHost: if self.webhook_id is None: self.register_webhook() - if self._api.subscribed: + if self._api.subscribed(SubType.push): _LOGGER.debug( "Host %s: is already subscribed to webhook %s", self._api.host, @@ -283,7 +317,9 @@ class ReolinkHost: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" try: - await self._renew() + await self._renew(SubType.push) + if self._long_poll_task is not None: + await self._renew(SubType.long_poll) except SubscriptionError as err: if not self._lost_subscription: self._lost_subscription = True @@ -295,22 +331,27 @@ class ReolinkHost: else: self._lost_subscription = False - async def _renew(self) -> None: + async def _renew(self, sub_type: SubType) -> None: """Execute the renew of the subscription.""" - if not self._api.subscribed: + if not self._api.subscribed(sub_type): _LOGGER.debug( - "Host %s: requested to renew a non-existing Reolink subscription, " + "Host %s: requested to renew a non-existing Reolink %s subscription, " "trying to subscribe from scratch", self._api.host, + sub_type, ) - await self.subscribe() + if sub_type == SubType.push: + await self.subscribe() + else: + await self._api.subscribe(self._webhook_url, sub_type) return - timer = self._api.renewtimer + timer = self._api.renewtimer(sub_type) _LOGGER.debug( - "Host %s:%s should renew subscription in: %i seconds", + "Host %s:%s should renew %s subscription in: %i seconds", self._api.host, self._api.port, + sub_type, timer, ) if timer > SUBSCRIPTION_RENEW_THRESHOLD: @@ -318,25 +359,29 @@ class ReolinkHost: if timer > 0: try: - await self._api.renew() + await self._api.renew(sub_type) except SubscriptionError as err: _LOGGER.debug( - "Host %s: error renewing Reolink subscription, " + "Host %s: error renewing Reolink %s subscription, " "trying to subscribe again: %s", self._api.host, + sub_type, err, ) else: _LOGGER.debug( - "Host %s successfully renewed Reolink subscription", self._api.host + "Host %s successfully renewed Reolink %s subscription", + self._api.host, + sub_type, ) return - await self._api.subscribe(self._webhook_url) + await self._api.subscribe(self._webhook_url, sub_type) _LOGGER.debug( - "Host %s: Reolink re-subscription successful after it was expired", + "Host %s: Reolink %s re-subscription successful after it was expired", self._api.host, + sub_type, ) def register_webhook(self) -> None: @@ -387,31 +432,56 @@ class ReolinkHost: webhook.async_unregister(self._hass, self.webhook_id) self.webhook_id = None + async def _async_long_polling(self, *_) -> None: + """Use ONVIF long polling to immediately receive events.""" + # This task will be cancelled once _async_stop_long_polling is called + while True: + if self._webhook_reachable: + self._long_poll_task = None + await self._async_stop_long_polling() + return + + try: + channels = await self._api.pull_point_request() + except ReolinkError as ex: + if not self._long_poll_error: + _LOGGER.error("Error while requesting ONVIF pull point: %s", ex) + await self._api.unsubscribe(sub_type=SubType.long_poll) + self._long_poll_error = True + await asyncio.sleep(LONG_POLL_ERROR_COOLDOWN) + continue + except Exception as ex: + _LOGGER.exception("Error while requesting ONVIF pull point: %s", ex) + await self._api.unsubscribe(sub_type=SubType.long_poll) + raise ex + + self._long_poll_error = False + + if not self._long_poll_received and channels != []: + self._long_poll_received = True + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + + self._signal_write_ha_state(channels) + + # Cooldown to prevent CPU over usage on camera freezes + await asyncio.sleep(LONG_POLL_COOLDOWN) + async def _async_poll_all_motion(self, *_) -> None: """Poll motion and AI states until the first ONVIF push is received.""" - if self._webhook_reachable.is_set(): - # ONVIF push is working, stop polling + if self._webhook_reachable or self._long_poll_received: + # ONVIF push or long polling is working, stop fast polling self._cancel_poll = None return try: await self._api.get_motion_state_all_ch() - except ( - aiohttp.ClientConnectorError, - ReolinkError, - ) as err: + except ReolinkError as err: _LOGGER.error( "Reolink error while polling motion state for host %s:%s: %s", self._api.host, self._api.port, str(err), ) - except asyncio.TimeoutError: - _LOGGER.error( - "Reolink timeout error while polling motion state for host %s:%s", - self._api.host, - self._api.port, - ) finally: # schedule next poll if not self._hass.is_stopping: @@ -419,10 +489,7 @@ class ReolinkHost: self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job ) - # After receiving the new motion states in the upstream lib, - # update the binary sensors with async_write_ha_state - # The same dispatch as for the webhook can be used - async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {}) + self._signal_write_ha_state(None) async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request @@ -470,8 +537,8 @@ class ReolinkHost: """Process the data from the Reolink webhook.""" # This task is executed in the background so we need to catch exceptions # and log them - if not self._webhook_reachable.is_set(): - self._webhook_reachable.set() + if not self._webhook_reachable: + self._webhook_reachable = True ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") try: @@ -494,9 +561,13 @@ class ReolinkHost: ) return + self._signal_write_ha_state(channels) + + def _signal_write_ha_state(self, channels: list[int] | None) -> None: + """Update the binary sensors with async_write_ha_state.""" if channels is None: - async_dispatcher_send(hass, f"{webhook_id}_all", {}) + async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {}) return for channel in channels: - async_dispatcher_send(hass, f"{webhook_id}_{channel}", {}) + async_dispatcher_send(self._hass, f"{self.webhook_id}_{channel}", {}) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 413c106b53e..69b3d5db6f7 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.16"] + "requirements": ["reolink-aio==0.7.1"] } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 1a4deda17e3..aa121911758 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -98,6 +98,50 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.ptz_guard_enabled(ch), method=lambda api, ch, value: api.set_ptz_guard(ch, enable=value), ), + ReolinkSwitchEntityDescription( + key="email", + name="Email on event", + icon="mdi:email", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "email") and api.is_nvr, + value=lambda api, ch: api.email_enabled(ch), + method=lambda api, ch, value: api.set_email(ch, value), + ), + ReolinkSwitchEntityDescription( + key="ftp_upload", + name="FTP upload", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ftp") and api.is_nvr, + value=lambda api, ch: api.ftp_enabled(ch), + method=lambda api, ch, value: api.set_ftp(ch, value), + ), + ReolinkSwitchEntityDescription( + key="push_notifications", + name="Push notifications", + icon="mdi:message-badge", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "push") and api.is_nvr, + value=lambda api, ch: api.push_enabled(ch), + method=lambda api, ch, value: api.set_push(ch, value), + ), + ReolinkSwitchEntityDescription( + key="record", + name="Record", + icon="mdi:record-rec", + supported=lambda api, ch: api.supported(ch, "recording") and api.is_nvr, + value=lambda api, ch: api.recording_enabled(ch), + method=lambda api, ch, value: api.set_recording(ch, value), + ), + ReolinkSwitchEntityDescription( + key="buzzer", + name="Buzzer on event", + icon="mdi:room-service", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "buzzer") and api.is_nvr, + value=lambda api, ch: api.buzzer_enabled(ch), + method=lambda api, ch, value: api.set_buzzer(ch, value), + ), ReolinkSwitchEntityDescription( key="doorbell_button_sound", name="Doorbell button sound", diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index aeb44cb7740..fbbb037080b 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -5,6 +5,7 @@ import logging from typing import Any, Literal from reolink_aio.exceptions import ReolinkError +from reolink_aio.software_version import NewSoftwareVersion from homeassistant.components.update import ( UpdateDeviceClass, @@ -30,8 +31,7 @@ async def async_setup_entry( ) -> None: """Set up update entities for Reolink component.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - if reolink_data.host.api.supported(None, "update"): - async_add_entities([ReolinkUpdateEntity(reolink_data)]) + async_add_entities([ReolinkUpdateEntity(reolink_data)]) class ReolinkUpdateEntity( @@ -40,7 +40,6 @@ class ReolinkUpdateEntity( """Update entity for a Netgear device.""" _attr_device_class = UpdateDeviceClass.FIRMWARE - _attr_supported_features = UpdateEntityFeature.INSTALL _attr_release_url = "https://reolink.com/download-center/" _attr_name = "Update" @@ -64,7 +63,30 @@ class ReolinkUpdateEntity( if not self.coordinator.data: return self.installed_version - return self.coordinator.data + if isinstance(self.coordinator.data, str): + return self.coordinator.data + + return self.coordinator.data.version_string + + @property + def supported_features(self) -> UpdateEntityFeature: + """Flag supported features.""" + supported_features = UpdateEntityFeature.INSTALL + if isinstance(self.coordinator.data, NewSoftwareVersion): + supported_features |= UpdateEntityFeature.RELEASE_NOTES + return supported_features + + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + if not isinstance(self.coordinator.data, NewSoftwareVersion): + return None + + return ( + "If the install button fails, download this" + f" [firmware zip file]({self.coordinator.data.download_url})." + " Then, follow the installation guide (PDF in the zip file).\n\n" + f"## Release notes\n\n{self.coordinator.data.release_notes}" + ) async def async_install( self, version: str | None, backup: bool, **kwargs: Any diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 24c97c74b0f..784555e6c73 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL, RepetierSensorEntityDescription @@ -45,7 +45,8 @@ def setup_platform( sensor_type = info["sensor_type"] temp_id = info["temp_id"] description = SENSOR_TYPES[sensor_type] - name = f"{info['name']}{description.name or ''}" + name_suffix = "" if description.name is UNDEFINED else description.name + name = f"{info['name']}{name_suffix}" if temp_id is not None: _LOGGER.debug("%s Temp_id: %s", sensor_type, temp_id) name = f"{name}{temp_id}" diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index b249b7536b5..ee79c45921c 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Coroutine import contextlib from datetime import timedelta import logging -from typing import Any, cast +from typing import Any import httpx import voluptuous as vol @@ -160,11 +160,7 @@ def _rest_coordinator( if resource_template: async def _async_refresh_with_resource_template() -> None: - rest.set_url( - cast(template.Template, resource_template).async_render( - parse_result=False - ) - ) + rest.set_url(resource_template.async_render(parse_result=False)) await rest.async_update() update_method = _async_refresh_with_resource_template diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index 0bf0ea9743d..8fb08f766fa 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -26,3 +26,10 @@ REST = "rest" REST_DATA = "rest_data" METHODS = ["POST", "GET"] + +XML_MIME_TYPES = ( + "application/rss+xml", + "application/xhtml+xml", + "application/xml", + "text/xml", +) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 95086f68d70..1f331651165 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -3,14 +3,19 @@ from __future__ import annotations import logging import ssl +from xml.parsers.expat import ExpatError import httpx +import xmltodict from homeassistant.core import HomeAssistant from homeassistant.helpers import template from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.json import json_dumps from homeassistant.util.ssl import SSLCipherList +from .const import XML_MIME_TYPES + DEFAULT_TIMEOUT = 10 _LOGGER = logging.getLogger(__name__) @@ -59,6 +64,26 @@ class RestData: """Set url.""" self._resource = url + def data_without_xml(self) -> str | None: + """If the data is an XML string, convert it to a JSON string.""" + _LOGGER.debug("Data fetched from resource: %s", self.data) + if ( + (value := self.data) is not None + # If the http request failed, headers will be None + and (headers := self.headers) is not None + and (content_type := headers.get("content-type")) + and content_type.startswith(XML_MIME_TYPES) + ): + try: + value = json_dumps(xmltodict.parse(value)) + except ExpatError: + _LOGGER.warning( + "REST xml result could not be parsed and converted to JSON" + ) + else: + _LOGGER.debug("JSON converted from XML: %s", self.data) + return value + async def async_update(self, log_errors: bool = True) -> None: """Get the latest data from REST service with provided method.""" if not self._async_client: diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 6fc0b69d1fd..18d0b6c7e76 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -3,11 +3,9 @@ from __future__ import annotations import logging import ssl -from xml.parsers.expat import ExpatError from jsonpath import jsonpath import voluptuous as vol -import xmltodict from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -26,7 +24,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.json import json_dumps from homeassistant.helpers.template_entity import TemplateSensor from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -127,26 +124,7 @@ class RestSensor(RestEntity, TemplateSensor): def _update_from_rest_data(self) -> None: """Update state from the rest data.""" - value = self.rest.data - _LOGGER.debug("Data fetched from resource: %s", value) - if self.rest.headers is not None: - # If the http request failed, headers will be None - content_type = self.rest.headers.get("content-type") - - if content_type and ( - content_type.startswith("text/xml") - or content_type.startswith("application/xml") - or content_type.startswith("application/xhtml+xml") - or content_type.startswith("application/rss+xml") - ): - try: - value = json_dumps(xmltodict.parse(value)) - _LOGGER.debug("JSON converted from XML: %s", value) - except ExpatError: - _LOGGER.warning( - "REST xml result could not be parsed and converted to JSON" - ) - _LOGGER.debug("Erroneous XML: %s", value) + value = self.rest.data_without_xml() if self._json_attrs: self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 89b6529d483..342808f3250 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -232,12 +232,11 @@ class RestSwitch(TemplateEntity, SwitchEntity): self._attr_is_on = False else: self._attr_is_on = None + elif text == self._body_on.template: + self._attr_is_on = True + elif text == self._body_off.template: + self._attr_is_on = False else: - if text == self._body_on.template: - self._attr_is_on = True - elif text == self._body_off.template: - self._attr_is_on = False - else: - self._attr_is_on = None + self._attr_is_on = None return req diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index b563275297f..8df2d7ec343 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -323,7 +323,6 @@ class RflinkDevice(Entity): Contains the common logic for Rflink entities. """ - platform = None _state: bool | None = None _available = True _attr_should_poll = False diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index de8a9fc6b8d..3544abcfdd1 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -8,8 +8,8 @@ import copy import logging from typing import Any, NamedTuple, cast -import RFXtrx as rfxtrxmod import async_timeout +import RFXtrx as rfxtrxmod import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -548,6 +548,8 @@ class RfxtrxCommandEntity(RfxtrxEntity): Contains the common logic for Rfxtrx lights and switches. """ + _attr_name = None + def __init__( self, device: rfxtrxmod.RFXtrxDevice, diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index b729138f73e..03cf65a49ff 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -130,6 +130,7 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): """ _attr_force_update = True + _attr_name = None def __init__( self, diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 2e054ce4724..8d55208cbb7 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -8,8 +8,8 @@ import itertools import os from typing import Any, TypedDict, cast -import RFXtrx as rfxtrxmod from async_timeout import timeout +import RFXtrx as rfxtrxmod import serial import serial.tools.list_ports import voluptuous as vol diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py index 57919ed1feb..3ef3bbdc5ae 100644 --- a/homeassistant/components/ridwell/calendar.py +++ b/homeassistant/components/ridwell/calendar.py @@ -50,6 +50,7 @@ class RidwellCalendar(RidwellEntity, CalendarEntity): """Define a Ridwell calendar.""" _attr_icon = "mdi:delete-empty" + _attr_name = None def __init__( self, coordinator: RidwellDataUpdateCoordinator, account: RidwellAccount diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index fb037eca05d..56aad1a845b 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -6,7 +6,6 @@ from collections.abc import Callable from datetime import timedelta from functools import partial import logging -from pathlib import Path from typing import Any from oauthlib.oauth2 import AccessDeniedError @@ -18,7 +17,6 @@ from homeassistant.const import Platform, __version__ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -41,22 +39,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Ring component.""" - if DOMAIN not in config: - return True - - def legacy_cleanup(): - """Clean up old tokens.""" - old_cache = Path(hass.config.path(".ring_cache.pickle")) - if old_cache.is_file(): - old_cache.unlink() - - await hass.async_add_executor_job(legacy_cleanup) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 7cb34b4d71f..355c630272e 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring_doorbell==0.7.2"] + "requirements": ["ring-doorbell==0.7.2"] } diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 19732169b86..73499fb5ccc 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -39,7 +39,6 @@ class RitualsBinarySensorEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsBinarySensorEntityDescription( key="charging", - translation_key="charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda diffuser: diffuser.charging, diff --git a/homeassistant/components/rituals_perfume_genie/diagnostics.py b/homeassistant/components/rituals_perfume_genie/diagnostics.py new file mode 100644 index 00000000000..75b622b48b1 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for Rituals Perfume Genie.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator + +TO_REDACT = { + "hublot", + "hash", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + entry.entry_id + ] + return { + "diffusers": [ + async_redact_data(coordinator.diffuser.data, TO_REDACT) + for coordinator in coordinators.values() + ] + } diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 944efb21536..09189dabfad 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -40,7 +40,6 @@ class RitualsSensorEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsSensorEntityDescription( key="battery_percentage", - translation_key="battery_percentage", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, value_fn=lambda diffuser: diffuser.battery_percentage, diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json index f4570dd4cfc..48e9be670ec 100644 --- a/homeassistant/components/rituals_perfume_genie/strings.json +++ b/homeassistant/components/rituals_perfume_genie/strings.json @@ -19,11 +19,6 @@ } }, "entity": { - "binary_sensor": { - "charging": { - "name": "[%key:component::binary_sensor::entity_component::battery_charging::name%]" - } - }, "number": { "perfume_amount": { "name": "Perfume amount" @@ -35,9 +30,6 @@ } }, "sensor": { - "battery_percentage": { - "name": "[%key:component::sensor::entity_component::battery::name%]" - }, "fill": { "name": "Fill" }, diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index a6083e51430..77776704a60 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -36,6 +36,7 @@ class RitualsSwitchEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsSwitchEntityDescription( key="is_on", + name=None, icon="mdi:fan", is_on_fn=lambda diffuser: diffuser.is_on, turn_on_fn=lambda diffuser: diffuser.turn_on(), diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 41db2cc08ac..90ca13c5146 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -2,6 +2,8 @@ from typing import Any +from roborock.api import AttributeCache +from roborock.command_cache import CacheableAttribute from roborock.containers import Status from roborock.exceptions import RoborockException from roborock.local_api import RoborockLocalClient @@ -27,6 +29,15 @@ class RoborockEntity(Entity): self._attr_device_info = device_info self._api = api + @property + def api(self) -> RoborockLocalClient: + """Returns the api.""" + return self._api + + def get_cache(self, attribute: CacheableAttribute) -> AttributeCache: + """Get an item from the api cache.""" + return self._api.cache.get(attribute) + async def send( self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None ) -> dict: @@ -72,3 +83,13 @@ class RoborockCoordinatedEntity( if status: return status return Status({}) + + async def send( + self, + command: RoborockCommand, + params: dict[str, Any] | list[Any] | None = None, + ) -> dict: + """Overloads normal send command but refreshes coordinator.""" + res = await super().send(command, params) + await self.coordinator.async_refresh() + return res diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 0cd437278cf..baab687e64a 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.23.4"] + "requirements": ["python-roborock==0.29.2"] } diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index ec973addae3..8398995462f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import AREA_SQUARE_METERS, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -99,6 +99,20 @@ SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, options=RoborockStateCode.keys(), ), + RoborockSensorDescription( + key="cleaning_area", + icon="mdi:texture-box", + translation_key="cleaning_area", + value_fn=lambda data: data.status.square_meter_clean_area, + native_unit_of_measurement=AREA_SQUARE_METERS, + ), + RoborockSensorDescription( + key="total_cleaning_area", + icon="mdi:texture-box", + translation_key="total_cleaning_area", + value_fn=lambda data: data.clean_summary.square_meter_clean_area, + native_unit_of_measurement=AREA_SQUARE_METERS, + ), ] @@ -119,6 +133,7 @@ async def async_setup_entry( ) for device_id, coordinator in coordinators.items() for description in SENSOR_DESCRIPTIONS + if description.value_fn(coordinator.roborock_device_info.props) is not None ) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 00ebd3833a8..f711ceaf74a 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -28,6 +28,9 @@ }, "entity": { "sensor": { + "cleaning_area": { + "name": "Cleaning area" + }, "cleaning_time": { "name": "Cleaning time" }, @@ -73,6 +76,9 @@ }, "total_cleaning_time": { "name": "Total cleaning time" + }, + "total_cleaning_area": { + "name": "Total cleaning area" } }, "select": { @@ -104,6 +110,9 @@ "child_lock": { "name": "Child lock" }, + "dnd_switch": { + "name": "Do not disturb" + }, "status_indicator": { "name": "Status indicator light" } diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index d8ff50430cb..a0b3d5be295 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -1,17 +1,21 @@ """Support for Roborock switch.""" +from __future__ import annotations + import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging from typing import Any -from roborock.exceptions import RoborockException -from roborock.roborock_typing import RoborockCommand +from roborock.api import AttributeCache +from roborock.command_cache import CacheableAttribute +from roborock.local_api import RoborockLocalClient from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -27,13 +31,11 @@ class RoborockSwitchDescriptionMixin: """Define an entity description mixin for switch entities.""" # Gets the status of the switch - get_value: Callable[[RoborockEntity], Coroutine[Any, Any, dict]] - # Evaluate the result of get_value to determine a bool - evaluate_value: Callable[[dict], bool] + cache_key: CacheableAttribute # Sets the status of the switch - set_command: Callable[[RoborockEntity, bool], Coroutine[Any, Any, dict]] - # Check support of this feature - check_support: Callable[[RoborockDataUpdateCoordinator], Coroutine[Any, Any, dict]] + update_value: Callable[[AttributeCache, bool], Coroutine[Any, Any, dict]] + # Attribute from cache + attribute: str @dataclass @@ -45,33 +47,45 @@ class RoborockSwitchDescription( SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ RoborockSwitchDescription( - set_command=lambda entity, value: entity.send( - RoborockCommand.SET_CHILD_LOCK_STATUS, {"lock_status": 1 if value else 0} + cache_key=CacheableAttribute.child_lock_status, + update_value=lambda cache, value: cache.update_value( + {"lock_status": 1 if value else 0} ), - get_value=lambda data: data.send(RoborockCommand.GET_CHILD_LOCK_STATUS), - check_support=lambda data: data.api.send_command( - RoborockCommand.GET_CHILD_LOCK_STATUS - ), - evaluate_value=lambda data: data["lock_status"] == 1, + attribute="lock_status", key="child_lock", translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), RoborockSwitchDescription( - set_command=lambda entity, value: entity.send( - RoborockCommand.SET_FLOW_LED_STATUS, {"status": 1 if value else 0} + cache_key=CacheableAttribute.flow_led_status, + update_value=lambda cache, value: cache.update_value( + {"status": 1 if value else 0} ), - get_value=lambda data: data.send(RoborockCommand.GET_FLOW_LED_STATUS), - check_support=lambda data: data.api.send_command( - RoborockCommand.GET_FLOW_LED_STATUS - ), - evaluate_value=lambda data: data["status"] == 1, + attribute="status", key="status_indicator", translation_key="status_indicator", icon="mdi:alarm-light-outline", entity_category=EntityCategory.CONFIG, ), + RoborockSwitchDescription( + cache_key=CacheableAttribute.dnd_timer, + update_value=lambda cache, value: cache.update_value( + [ + cache.value.get("start_hour"), + cache.value.get("start_minute"), + cache.value.get("end_hour"), + cache.value.get("end_minute"), + ] + ) + if value + else cache.close_value(), + attribute="enabled", + key="dnd_switch", + translation_key="dnd_switch", + icon="mdi:bell-cancel", + entity_category=EntityCategory.CONFIG, + ), ] @@ -81,73 +95,74 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Roborock switch platform.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ config_entry.entry_id ] possible_entities: list[ - tuple[str, RoborockDataUpdateCoordinator, RoborockSwitchDescription] + tuple[RoborockDataUpdateCoordinator, RoborockSwitchDescription] ] = [ - (device_id, coordinator, description) - for device_id, coordinator in coordinators.items() + (coordinator, description) + for coordinator in coordinators.values() for description in SWITCH_DESCRIPTIONS ] # We need to check if this function is supported by the device. results = await asyncio.gather( *( - description.check_support(coordinator) - for _, coordinator, description in possible_entities + coordinator.api.cache.get(description.cache_key).async_value() + for coordinator, description in possible_entities ), return_exceptions=True, ) - valid_entities: list[RoborockSwitchEntity] = [] - for posible_entity, result in zip(possible_entities, results): - if isinstance(result, Exception): - if not isinstance(result, RoborockException): - raise result + valid_entities: list[RoborockSwitch] = [] + for (coordinator, description), result in zip(possible_entities, results): + if result is None or isinstance(result, Exception): _LOGGER.debug("Not adding entity because of %s", result) else: valid_entities.append( - RoborockSwitchEntity( - f"{posible_entity[2].key}_{slugify(posible_entity[0])}", - posible_entity[1], - posible_entity[2], - result, + RoborockSwitch( + f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", + coordinator.device_info, + description, + coordinator.api, ) ) - async_add_entities( - valid_entities, - True, - ) + async_add_entities(valid_entities) -class RoborockSwitchEntity(RoborockEntity, SwitchEntity): - """A class to let you turn functionality on Roborock devices on and off.""" +class RoborockSwitch(RoborockEntity, SwitchEntity): + """A class to let you turn functionality on Roborock devices on and off that does need a coordinator.""" entity_description: RoborockSwitchDescription def __init__( self, unique_id: str, - coordinator: RoborockDataUpdateCoordinator, - entity_description: RoborockSwitchDescription, - initial_value: bool, + device_info: DeviceInfo, + description: RoborockSwitchDescription, + api: RoborockLocalClient, ) -> None: - """Create a switch entity.""" - self.entity_description = entity_description - super().__init__(unique_id, coordinator.device_info, coordinator.api) - self._attr_is_on = initial_value + """Initialize the entity.""" + super().__init__(unique_id, device_info, api) + self.entity_description = description async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - await self.entity_description.set_command(self, False) + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), False + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - await self.entity_description.set_command(self, True) - - async def async_update(self) -> None: - """Update switch.""" - self._attr_is_on = self.entity_description.evaluate_value( - await self.entity_description.get_value(self) + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), True + ) + + @property + def is_on(self) -> bool | None: + """Return True if entity is on.""" + return ( + self.get_cache(self.entity_description.cache_key).value.get( + self.entity_description.attribute + ) + == 1 ) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 9e486fa54b1..5f66338ecc1 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -83,6 +83,7 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): | VacuumEntityFeature.START ) _attr_translation_key = DOMAIN + _attr_name = None def __init__( self, @@ -144,7 +145,6 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): RoborockCommand.SET_CUSTOM_MODE, [self._device_status.fan_power.as_dict().get(fan_speed)], ) - await self.coordinator.async_request_refresh() async def async_start_pause(self) -> None: """Start, pause or resume the cleaning task.""" diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 877e58233d5..a8c1cf4698c 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -108,6 +108,7 @@ async def async_setup_entry( class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): """Representation of a Roku media player on the network.""" + _attr_name = None _attr_supported_features = ( MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index fceac67a477..0271e4a0f73 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -37,6 +37,8 @@ async def async_setup_entry( class RokuRemote(RokuEntity, RemoteEntity): """Device that sends commands to an Roku.""" + _attr_name = None + @property def is_on(self) -> bool: """Return true if device is on.""" diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 3bcafe4ba9a..6d096ea8b1a 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from roonapi import split_media_path import voluptuous as vol @@ -159,7 +159,10 @@ class RoonDevice(MediaPlayerEntity): dev_model = self.player_data["source_controls"][0].get("display_name") return DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, - name=self.name, + # Instead of setting the device name to the entity name, roon + # should be updated to set has_entity_name = True, and set the entity + # name to None + name=cast(str | None, self.name), manufacturer="RoonLabs", model=dev_model, via_device=(DOMAIN, self._server.roon_id), diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 4dd973155a9..47a9bbfdde0 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -9,6 +9,7 @@ from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + MONOTONIC_TIME, BaseHaRemoteScanner, async_get_advertisement_callback, async_register_scanner, @@ -47,6 +48,7 @@ class RuuviGatewayScanner(BaseHaRemoteScanner): @callback def _async_handle_new_data(self) -> None: now = time.time() + monotonic_now = MONOTONIC_TIME() for tag_data in self.coordinator.data: data_age_seconds = now - tag_data.timestamp # Both are Unix time if data_age_seconds > FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: @@ -62,6 +64,7 @@ class RuuviGatewayScanner(BaseHaRemoteScanner): manufacturer_data=anno.manufacturer_data, tx_power=anno.tx_power, details={}, + advertisement_monotonic_time=monotonic_now - data_age_seconds, ) @callback diff --git a/homeassistant/components/ruuvitag_ble/manifest.json b/homeassistant/components/ruuvitag_ble/manifest.json index 6cabecb7912..fa8ec80423c 100644 --- a/homeassistant/components/ruuvitag_ble/manifest.json +++ b/homeassistant/components/ruuvitag_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble", "iot_class": "local_push", - "requirements": ["ruuvitag-ble==0.1.1"] + "requirements": ["ruuvitag-ble==0.1.2"] } diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index ba09cf9fe3b..0cc4dd556d5 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -549,7 +549,7 @@ class SamsungTVWSBridge( except (ConnectionFailure, OSError, AsyncioTimeoutError) as err: LOGGER.debug("Failing config: %s, %s error: %s", config, type(err), err) # pylint: disable-next=useless-else-on-loop - else: + else: # noqa: PLW0120 if result: return result diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index f98e3667b59..124dab73004 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -184,7 +184,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): raise AbortFlow(result) assert method is not None self._bridge = SamsungTVBridge.get_bridge(self.hass, method, self._host) - return async def _async_get_device_info_and_method( self, diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 418feecbf94..4d5ea3d5fab 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -1,6 +1,8 @@ """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 @@ -20,7 +22,9 @@ class SamsungTVEntity(Entity): self._attr_name = config_entry.data.get(CONF_NAME) self._attr_unique_id = config_entry.unique_id self._attr_device_info = DeviceInfo( - name=self.name, + # 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), manufacturer=config_entry.data.get(CONF_MANUFACTURER), model=config_entry.data.get(CONF_MODEL), ) diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py index 389bde884ef..5d2ce2c193c 100644 --- a/homeassistant/components/satel_integra/binary_sensor.py +++ b/homeassistant/components/satel_integra/binary_sensor.py @@ -80,11 +80,10 @@ class SatelIntegraBinarySensor(BinarySensorEntity): self._state = 1 else: self._state = 0 + elif self._device_number in self._satel.violated_zones: + self._state = 1 else: - if self._device_number in self._satel.violated_zones: - self._state = 1 - else: - self._state = 0 + self._state = 0 self.async_on_remove( async_dispatcher_connect( self.hass, self._react_to_signal, self._devices_updated diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index ffb2c1a3af2..828261aa466 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/satel_integra", "iot_class": "local_push", "loggers": ["satel_integra"], - "requirements": ["satel_integra==0.3.7"] + "requirements": ["satel-integra==0.3.7"] } diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 857d53eb527..e5ed8613fc4 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -171,6 +171,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%]", "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%]", diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 6662c20ad4f..3370c196c3c 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -168,4 +168,3 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): if self.gateway.is_connected: await self.gateway.async_disconnect() raise UpdateFailed(ex.msg) from ex - return None diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 659131e902b..f8d41db0e11 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -1,6 +1,7 @@ """Support for scripts.""" from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from dataclasses import dataclass import logging @@ -28,7 +29,14 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + Context, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema @@ -87,12 +95,12 @@ def _scripts_with_x( if DOMAIN not in hass.data: return [] - component: EntityComponent[ScriptEntity] = hass.data[DOMAIN] + component: EntityComponent[BaseScriptEntity] = hass.data[DOMAIN] return [ script_entity.entity_id for script_entity in component.entities - if referenced_id in getattr(script_entity.script, property_name) + if referenced_id in getattr(script_entity, property_name) ] @@ -101,12 +109,12 @@ def _x_in_script(hass: HomeAssistant, entity_id: str, property_name: str) -> lis if DOMAIN not in hass.data: return [] - component: EntityComponent[ScriptEntity] = hass.data[DOMAIN] + component: EntityComponent[BaseScriptEntity] = hass.data[DOMAIN] if (script_entity := component.get_entity(entity_id)) is None: return [] - return list(getattr(script_entity.script, property_name)) + return list(getattr(script_entity, property_name)) @callback @@ -151,7 +159,7 @@ def scripts_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[str if DOMAIN not in hass.data: return [] - component: EntityComponent[ScriptEntity] = hass.data[DOMAIN] + component: EntityComponent[BaseScriptEntity] = hass.data[DOMAIN] return [ script_entity.entity_id @@ -166,7 +174,7 @@ def blueprint_in_script(hass: HomeAssistant, entity_id: str) -> str | None: if DOMAIN not in hass.data: return None - component: EntityComponent[ScriptEntity] = hass.data[DOMAIN] + component: EntityComponent[BaseScriptEntity] = hass.data[DOMAIN] if (script_entity := component.get_entity(entity_id)) is None: return None @@ -176,7 +184,9 @@ def blueprint_in_script(hass: HomeAssistant, entity_id: str) -> str | None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Load the scripts from the configuration.""" - hass.data[DOMAIN] = component = EntityComponent[ScriptEntity](LOGGER, DOMAIN, hass) + hass.data[DOMAIN] = component = EntityComponent[BaseScriptEntity]( + LOGGER, DOMAIN, hass + ) # Process integration platforms right away since # we will create entities before firing EVENT_COMPONENT_LOADED @@ -253,6 +263,7 @@ class ScriptEntityConfig: key: str raw_blueprint_inputs: ConfigType | None raw_config: ConfigType | None + validation_failed: bool async def _prepare_script_config( @@ -267,9 +278,12 @@ async def _prepare_script_config( for key, config_block in conf.items(): raw_config = cast(ScriptConfig, config_block).raw_config raw_blueprint_inputs = cast(ScriptConfig, config_block).raw_blueprint_inputs + validation_failed = cast(ScriptConfig, config_block).validation_failed script_configs.append( - ScriptEntityConfig(config_block, key, raw_blueprint_inputs, raw_config) + ScriptEntityConfig( + config_block, key, raw_blueprint_inputs, raw_config, validation_failed + ) ) return script_configs @@ -277,11 +291,20 @@ async def _prepare_script_config( async def _create_script_entities( hass: HomeAssistant, script_configs: list[ScriptEntityConfig] -) -> list[ScriptEntity]: +) -> list[BaseScriptEntity]: """Create script entities from prepared configuration.""" - entities: list[ScriptEntity] = [] + entities: list[BaseScriptEntity] = [] for script_config in script_configs: + if script_config.validation_failed: + entities.append( + UnavailableScriptEntity( + script_config.key, + script_config.raw_config, + ) + ) + continue + entity = ScriptEntity( hass, script_config.key, @@ -295,16 +318,20 @@ async def _create_script_entities( async def _async_process_config( - hass: HomeAssistant, config: ConfigType, component: EntityComponent[ScriptEntity] + hass: HomeAssistant, + config: ConfigType, + component: EntityComponent[BaseScriptEntity], ) -> None: """Process script configuration.""" entities = [] - def script_matches_config(script: ScriptEntity, config: ScriptEntityConfig) -> bool: + def script_matches_config( + script: BaseScriptEntity, config: ScriptEntityConfig + ) -> bool: return script.unique_id == config.key and script.raw_config == config.raw_config def find_matches( - scripts: list[ScriptEntity], + scripts: list[BaseScriptEntity], script_configs: list[ScriptEntityConfig], ) -> tuple[set[int], set[int]]: """Find matches between a list of script entities and a list of configurations. @@ -331,7 +358,7 @@ async def _async_process_config( return script_matches, config_matches script_configs = await _prepare_script_config(hass, config) - scripts: list[ScriptEntity] = list(component.entities) + scripts: list[BaseScriptEntity] = list(component.entities) # Find scripts and configurations which have matches script_matches, config_matches = find_matches(scripts, script_configs) @@ -352,7 +379,78 @@ async def _async_process_config( await component.async_add_entities(entities) -class ScriptEntity(ToggleEntity, RestoreEntity): +class BaseScriptEntity(ToggleEntity, ABC): + """Base class for script entities.""" + + raw_config: ConfigType | None + + @property + @abstractmethod + def referenced_areas(self) -> set[str]: + """Return a set of referenced areas.""" + + @property + @abstractmethod + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + + @property + @abstractmethod + def referenced_devices(self) -> set[str]: + """Return a set of referenced devices.""" + + @property + @abstractmethod + def referenced_entities(self) -> set[str]: + """Return a set of referenced entities.""" + + +class UnavailableScriptEntity(BaseScriptEntity): + """A non-functional script entity with its state set to unavailable. + + This class is instatiated when an script fails to validate. + """ + + _attr_should_poll = False + _attr_available = False + + def __init__( + self, + key: str, + raw_config: ConfigType | None, + ) -> None: + """Initialize a script entity.""" + self._name = raw_config.get(CONF_ALIAS, key) if raw_config else key + self._attr_unique_id = key + self.raw_config = raw_config + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def referenced_areas(self) -> set[str]: + """Return a set of referenced areas.""" + return set() + + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + return None + + @property + def referenced_devices(self) -> set[str]: + """Return a set of referenced devices.""" + return set() + + @property + def referenced_entities(self) -> set[str]: + """Return a set of referenced entities.""" + return set() + + +class ScriptEntity(BaseScriptEntity, RestoreEntity): """Representation of a script entity.""" icon = None @@ -414,6 +512,11 @@ class ScriptEntity(ToggleEntity, RestoreEntity): """Return true if script is on.""" return self.script.is_running + @property + def referenced_areas(self) -> set[str]: + """Return a set of referenced areas.""" + return self.script.referenced_areas + @property def referenced_blueprint(self): """Return referenced blueprint or None.""" @@ -421,6 +524,16 @@ class ScriptEntity(ToggleEntity, RestoreEntity): return None return self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH] + @property + def referenced_devices(self) -> set[str]: + """Return a set of referenced devices.""" + return self.script.referenced_devices + + @property + def referenced_entities(self) -> set[str]: + """Return a set of referenced entities.""" + return self.script.referenced_entities + @callback def async_change_listener(self): """Update state.""" @@ -436,6 +549,12 @@ class ScriptEntity(ToggleEntity, RestoreEntity): variables = kwargs.get("variables") context = kwargs.get("context") wait = kwargs.get("wait", True) + await self._async_start_run(variables, context, wait) + + async def _async_start_run( + self, variables: dict, context: Context, wait: bool + ) -> ServiceResponse: + """Start the run of a script.""" self.async_set_context(context) self.hass.bus.async_fire( EVENT_SCRIPT_STARTED, @@ -444,8 +563,7 @@ class ScriptEntity(ToggleEntity, RestoreEntity): ) coro = self._async_run(variables, context) if wait: - await coro - return + return await coro # Caller does not want to wait for called script to finish so let script run in # separate Task. Make a new empty script stack; scripts are allowed to @@ -457,6 +575,7 @@ class ScriptEntity(ToggleEntity, RestoreEntity): # Wait for first state change so we can guarantee that # it is written to the State Machine before we return. await self._changed.wait() + return None async def _async_run(self, variables, context): with trace_script( @@ -483,16 +602,25 @@ class ScriptEntity(ToggleEntity, RestoreEntity): """ await self.script.async_stop() - async def _service_handler(self, service: ServiceCall) -> None: + async def _service_handler(self, service: ServiceCall) -> ServiceResponse: """Execute a service call to script.